Files

15772 lines
654 KiB
Python

"""
Custom-node SDK: resources as abstractions (refs), a brokered ``ctx``, and a
provider registry that lets a separable overlay swap the in-process default
implementation for an isolated one — without the node author changing a line.
Design (see docs/secure_custom_nodes_WIP.md):
* A node's heavyweight values cross ``execute()`` as opaque **refs**
(``ImageRef``, ``ModelRef``, ``AssetRef`` ...), never raw buffers/paths.
* Side effects go through **ctx** domains (``ctx.assets``, ``ctx.progress`` ...)
rather than ``folder_paths`` / ``PromptServer`` / ambient globals.
* Everything here ships a **default in-process implementation** so open-source
ComfyUI behaves exactly as today (a ref just wraps the real object; ctx is a
thin passthrough). Zero behavior change, better authoring API.
* The **overlay** (proprietary, cloud-only, loaded by ``COMFY_OVERLAY_MODULE``)
calls ``providers.register_*`` at import to replace those defaults with the
isolated engine: out-of-process guests, shm/CUDA-IPC refs, an enforcing
broker. Uninstall the overlay -> pure OSS.
Nothing isolation-specific lives in this file. This is the *seam*, not the
engine.
"""
from __future__ import annotations
import asyncio
import contextvars
import logging
import os
import re
import sys
import threading
import uuid
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Optional,
Protocol,
Sequence,
runtime_checkable,
)
from ._profiling import InProcessProfiling
from ._preview_override import InProcessPreviewOverride
from ._anima import InProcessAnima
from ._civitai import InProcessCivitai
from ._ollama import InProcessOllama
from ._llama_cpp import InProcessLlamaCpp
if TYPE_CHECKING: # keep this module import-safe / torch-free at import time
import torch
logger = logging.getLogger(__name__)
# Env var an operator points at a directory/module implementing ``register``.
OVERLAY_ENV = "COMFY_OVERLAY_MODULE"
# --------------------------------------------------------------------------- #
# Refs — opaque, typed handles. In OSS a ref is resolved by an identity table
# (zero-copy, zero-overhead: it holds the real object). The overlay swaps the
# resolver for shm/CUDA-IPC across a process boundary. The ref token itself
# carries nothing exploitable; the host table is authoritative.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Ref:
"""Base opaque resource handle. ``kind`` is the io_type (IMAGE, LATENT...)."""
kind: str
id: str
def __repr__(self) -> str: # keep ids short in logs, never leak contents
return f"<{type(self).__name__} {self.id[:8]}>"
async def release(self) -> None:
"""Optional early free. **A node never has to call this.**
Every ref a node receives or creates is released when `execute` returns,
so the ordinary node — materialize, compute, return — manages nothing.
That is deliberate: an API whose correctness depends on authors
remembering to free is an API that leaks in the field.
This exists for the uncommon node that builds many large intermediates
in a loop and wants them gone before the end of the call::
for step in steps:
nxt = await img.op("scale", factor=0.99)
await img.release() # optional: drop the previous one now
img = nxt
Using a released ref raises rather than resolving to anything else, so
an early free that turns out to be wrong fails loudly and immediately.
"""
await current_runtime().refs.release(self)
async def describe(self, max_value_chars: int = 32768) -> dict[str, Any]:
"""Return a bounded, inert description of this opaque value.
The host projects only canonical kind/type, collection length, tensor
shape, and a short redacted summary. It never calls arbitrary object
``repr``, iteration, properties, or methods, and never exposes model
weights, tensor values, paths, or the underlying host object.
"""
return await current_runtime().ops.apply(
"ref.describe", self, {"max_value_chars": max_value_chars})
class _TypedRef(Ref):
KIND: str = "ANY"
@classmethod
def _wrap(cls, ref: Ref) -> "Ref":
return cls(kind=cls.KIND, id=ref.id)
class ClosureRef(_TypedRef):
"""Handle to a retained node closure (D21).
A pack function the host invokes at a declared sampling phase, for the
rest of the prompt. The handle is what a node passes on — typically to
``ModelRef.patch("attach_closure", ...)`` — and carries no authority by
itself: the host resolves it against the registry entry owning both the
closure id and its validated captures, and releases both together at the
prompt boundary.
"""
KIND = "CLOSURE"
async def attach_model(self, model: "ModelRef") -> "ModelRef":
"""Attach this closure to its declared canonical model phase.
The host validates the closure kind, clones the model, and installs
ComfyUI's matching pre- or post-CFG hook; the pack's function remains
in its sandbox.
"""
return await current_runtime().ctx.closures.attach_model(self, model)
async def wrap_sampler(
self, sampler: "SamplerRef", *,
start_percent: Optional[float] = None,
end_percent: Optional[float] = None,
) -> "SamplerRef":
"""Wrap a sampler's model calls with a ``model_sigma`` closure."""
return await current_runtime().ctx.closures.attach_sampler(
self,
sampler,
start_percent=start_percent,
end_percent=end_percent,
)
async def as_latent_operation(self) -> "LatentOperationRef":
"""Expose a ``latent_operation`` closure as LATENT_OPERATION."""
return await current_runtime().ctx.closures.create_latent_operation(
self)
async def as_sampler(self) -> "SamplerRef":
"""Expose a ``custom_sampler`` closure as a host-owned SAMPLER.
The pack closure owns only the integration loop. During one sampling
invocation the host supplies a narrow broker for denoise, noise,
preview, and model-schedule projections; the broker is dead as soon as
that invocation returns.
"""
return await current_runtime().ctx.closures.create_sampler(self)
class LatentOperationRef(_TypedRef):
"""Handle to a host-owned LATENT_OPERATION callable."""
KIND = "LATENT_OPERATION"
class ValueRef(_TypedRef):
"""A handle whose value is buffer-safe structured data."""
KIND = "VALUE"
async def value(self) -> Any:
"""Read a dict/list of tensors and JSON scalars."""
return await current_runtime().refs.resolve(self)
@classmethod
async def from_value(cls, v: Any) -> "Ref":
return cls._wrap(await current_runtime().refs.create(cls.KIND, v))
class InterpolationStatesRef(_TypedRef):
"""A frame-interpolation skip policy projected from another pack."""
KIND = "INTERPOLATION_STATES"
async def skip_mask(self, pair_count: int) -> list[bool]:
"""Return which source-frame pairs must not be interpolated."""
return await current_runtime().ops.apply(
"interpolation_states.skip_mask", self,
{"pair_count": pair_count},
)
class TensorRef(_TypedRef):
KIND = "TENSOR"
# --- RAW ESCAPE HATCH (permissioned, discouraged) -------------------- #
# `raw()` returns the underlying buffer object. It is NOT the preferred
# interface: prefer operations on the asset (see ImageRef below). Raw
# access requires the `raw`/`tensor.read` capability and forces a node to
# the dedicated tier under the overlay. The SDK contract does not depend on
# torch; the return is deliberately untyped (Any) here.
async def raw(self) -> Any:
return await current_runtime().refs.resolve(self)
@classmethod
async def _from_raw(cls, obj: Any) -> "TensorRef":
return cls._wrap(await current_runtime().refs.create(cls.KIND, obj)) # type: ignore[return-value]
class ImageRef(TensorRef):
KIND = "IMAGE"
# --- PREFERRED INTERFACE: operations on the asset, by name. The heavy
# compute runs engine-side (trusted plane); the node never receives a
# buffer. `op` is generic dispatch — core ships a tiny built-in set and
# an overlay can extend the vocabulary without changing this contract.
async def op(self, name: str, **params: Any) -> "ImageRef":
return await current_runtime().ops.apply(name, self, params)
# Convenience wrappers over the built-in image primitives.
async def invert(self) -> "ImageRef":
return await self.op("invert")
async def scale(self, factor: float) -> "ImageRef":
return await self.op("scale", factor=factor)
async def rgb(self) -> "ImageRef":
"""Return the first three channels of an image as an opaque ref."""
return await self.op("image.rgb")
async def to_device(self, device: str = "auto") -> "ImageRef":
"""Clone this image onto a named ComfyUI-managed device.
The device lookup and tensor move happen on the trusted plane. Guests
choose only ``auto``, ``cpu``, or ``gpu`` and never receive a device
object or a raw tensor.
"""
return await self.op("image.to_device", device=str(device))
async def spatial_shape(self) -> tuple[int, int]:
"""Return image height and width without exposing its pixel buffer."""
result = await self.op("image.spatial_shape")
return int(result[0]), int(result[1])
async def batch_size(self) -> int:
"""Return the number of images without exposing their pixel buffers."""
return int(await self.op("image.batch_size"))
async def select_batch(self, indices: list[int]) -> "ImageRef":
"""Select an ordered, bounded set of images from a BHWC batch."""
return await self.op("image.select_batch", indices=list(indices))
class MaskRef(TensorRef):
KIND = "MASK"
async def grow(
self, amount: int, tapered_corners: bool = False,
) -> "MaskRef":
"""Dilate or erode a mask through core's canonical morphology node."""
return await current_runtime().ops.apply(
"mask.grow", self, {
"amount": int(amount),
"tapered_corners": bool(tapered_corners),
})
class LatentRef(ValueRef):
KIND = "LATENT"
@classmethod
async def empty(
cls, width: int, height: int, batch_size: int = 1,
channels: int = 4,
spatial_downscale_ratio: Optional[int] = None,
) -> "LatentRef":
"""Create a bounded zero latent without granting raw tensor access."""
return await current_runtime().ops.apply(
"latent.empty", None, {
"width": int(width),
"height": int(height),
"batch_size": int(batch_size),
"channels": int(channels),
"spatial_downscale_ratio": spatial_downscale_ratio,
})
async def repeat_batch(self, amount: int) -> "LatentRef":
"""Repeat a latent through core's canonical batch operation."""
return await current_runtime().ops.apply(
"latent.repeat_batch", self, {"amount": int(amount)})
async def noise_mask(self) -> Optional["MaskRef"]:
"""Return the latent's optional noise mask as an opaque mask ref."""
return await current_runtime().ops.apply(
"latent.noise_mask", self, {})
async def spatial_shape(self) -> tuple[int, int]:
"""Return the latent sample height and width without exposing buffers."""
result = await current_runtime().ops.apply(
"latent.spatial_shape", self, {})
return int(result[0]), int(result[1])
async def resize(
self, width: int, height: int, method: str = "bilinear",
) -> "LatentRef":
"""Resize latent spatial cells with ComfyUI's canonical interpolators."""
return await current_runtime().ops.apply(
"latent.resize", self, {
"width": int(width),
"height": int(height),
"method": str(method),
})
async def random_noise(
self, seed: int, source: str = "cpu", batch_size: Optional[int] = None,
) -> TensorRef:
"""Generate latent-shaped noise with a host-owned CPU/GPU RNG.
RNG placement is an engine concern, especially for CUDA's distinct
sequence. Higher-level variation mixing remains node-pack code.
"""
return await current_runtime().ops.apply(
"latent.random_noise", self, {
"seed": int(seed),
"source": str(source),
"batch_size": batch_size,
})
async def composite(
self, source: "LatentRef", *, x: int = 0, y: int = 0,
resize_source: bool = False, mask: Optional["MaskRef"] = None,
) -> "LatentRef":
"""Composite a source latent using core's bounded latent operation."""
return await current_runtime().ops.apply(
"latent.composite", self, {
"source": source,
"x": int(x),
"y": int(y),
"resize_source": bool(resize_source),
"mask": mask,
})
async def minimax_h3_token_count(
self, conditioning: "CondRef",
) -> dict[str, Any]:
return await current_runtime().ops.apply(
"latent.minimax_h3_token_count", self,
{"conditioning": conditioning})
# --------------------------------------------------------------------------- #
# Handles for live engine objects — MODEL, CLIP, VAE, CONDITIONING, GUIDER.
#
# These keep the OLD API'S NATURAL SHAPE deliberately: you still write
# ``vae.decode(latent)`` and ``clip.encode(text)``, because that is the mental
# model every node author already has. What changes is what the call MEANS. In
# the old API the node held the VAE and ran the decode itself, which is exactly
# why a node taking a VAE could not be sandboxed. Here the node holds a handle,
# the call is awaited, and the decode happens on the trusted plane against
# weights the node never sees.
#
# Compatible in shape, different in substance — which is the point. Familiar
# enough to convert to mechanically; strict enough that a converted node is
# sandboxable by construction rather than by inspection.
#
# Dispatch goes through the same named-op registry ``ImageRef.op`` uses, so an
# overlay can extend the vocabulary without touching this contract.
# --------------------------------------------------------------------------- #
class CondRef(ValueRef):
KIND = "CONDITIONING"
async def sequence_length(self) -> int:
return int(await current_runtime().ops.apply(
"cond.sequence_length", self, {}))
async def combine(self, other: "CondRef") -> "CondRef":
return await current_runtime().ops.apply("cond.combine", self,
{"other": other})
async def concat(self, other: "CondRef") -> "CondRef":
return await current_runtime().ops.apply("cond.concat", self,
{"other": other})
async def zero_out(self) -> "CondRef":
"""Zero embeddings while preserving the conditioning structure."""
return await current_runtime().ops.apply("cond.zero_out", self, {})
async def with_timestep_range(
self, start: float, end: float,
) -> "CondRef":
"""Clone conditioning with a normalized sampling-percent range."""
return await current_runtime().ops.apply(
"cond.with_timestep_range", self, {
"start": float(start),
"end": float(end),
})
async def with_metadata(
self, *, width: Optional[int] = None,
height: Optional[int] = None,
crop_w: Optional[int] = None,
crop_h: Optional[int] = None,
target_width: Optional[int] = None,
target_height: Optional[int] = None,
) -> "CondRef":
"""Attach closed, scalar micro-conditioning metadata.
This deliberately exposes only the six conventional SDXL-style size
fields. Embeddings and arbitrary conditioning dictionaries remain
host-owned.
"""
return await current_runtime().ops.apply(
"cond.with_metadata", self, {
"width": width,
"height": height,
"crop_w": crop_w,
"crop_h": crop_h,
"target_width": target_width,
"target_height": target_height,
})
async def has_spatial_metadata(self) -> bool:
"""Whether tile-relative conditioning must be cropped per image."""
return bool(await current_runtime().ops.apply(
"cond.has_spatial_metadata", self, {}))
async def with_mask(
self, mask: MaskRef, strength: float = 1.0,
set_area_to_bounds: bool = False,
) -> "CondRef":
"""Attach core conditioning-mask metadata without exposing tensors."""
return await current_runtime().ops.apply(
"cond.with_mask", self, {
"mask": mask,
"strength": float(strength),
"set_area_to_bounds": bool(set_area_to_bounds),
})
async def with_clip_vision_output(
self, output: "ClipVisionOutputRef",
) -> "CondRef":
"""Attach one opaque CLIP-vision result to every conditioning row.
The vision features remain host-owned. This is the typed equivalent
of setting ComfyUI's conventional ``clip_vision_output`` conditioning
metadata key; prompt/layout policy stays with the calling node.
"""
return await current_runtime().ops.apply(
"cond.with_clip_vision_output", self, {"output": output})
async def with_concat_latent(
self, model: "ModelRef", latent: "LatentRef",
extra_latent: Optional["LatentRef"] = None,
) -> "CondRef":
"""Attach model-formatted latent ``c_concat`` conditioning.
This is the small reusable operation used by inpainting and layered
diffusion models. The guest chooses opaque latents; model-specific
latent-format conversion stays in the trusted process.
"""
return await current_runtime().ops.apply(
"cond.with_concat_latent", self, {
"model": model,
"latent": latent,
"extra_latent": extra_latent,
})
async def spatial_crop(
self, *, x: int, y: int, width: int, height: int,
source_width: int, source_height: int,
target_width: Optional[int] = None,
target_height: Optional[int] = None,
) -> "CondRef":
"""Crop 2D spatial conditioning to a latent-space window.
Embeddings stay unchanged. Area prompts, masks, GLIGEN regions, and
host-owned ControlNet/T2I hints are intersected with the requested
window so tiled samplers can remain ordinary pack-side orchestration.
Coordinates and dimensions use latent pixels.
"""
return await current_runtime().ops.apply(
"cond.spatial_crop", self, {
"x": x,
"y": y,
"width": width,
"height": height,
"source_width": source_width,
"source_height": source_height,
"target_width": target_width,
"target_height": target_height,
})
class GuiderRef(_TypedRef):
"""Opaque handle to a host-owned sampling guider."""
KIND = "GUIDER"
async def spatial_crop_inputs(
self, *, regions: list[tuple[int, int, int, int]],
source_width: int, source_height: int,
target_width: int, target_height: int,
) -> "GuiderRef":
"""Clone this guider and crop model-owned spatial inputs for tiles.
``regions`` are pixel-space ``(left, top, right, bottom)`` rectangles
on the source canvas; each result is resized to the target tile size.
Only model patches that explicitly implement the core spatial-input
protocol are changed; the guider policy stays opaque and otherwise
unchanged.
"""
return await current_runtime().ops.apply(
"sampling.spatial_crop_inputs", self, {
"regions": regions,
"source_width": source_width,
"source_height": source_height,
"target_width": target_width,
"target_height": target_height,
})
class SamplerRef(_TypedRef):
"""Opaque handle to a host-owned sampler."""
KIND = "SAMPLER"
@classmethod
async def named(
cls, name: str, *, eta: Optional[float] = None,
ge_gamma: Optional[float] = None,
) -> "SamplerRef":
"""Select a core sampler with its small, validated option set."""
return await current_runtime().ops.apply(
"sampler.named", None, {
"name": str(name),
"eta": eta,
"ge_gamma": ge_gamma,
})
@classmethod
async def self_refine_video(
cls, stochastic_steps: list[dict[str, int]],
certain_percentage: float, uncertainty_threshold: float,
seed: int, verbose: bool = False,
latent: Optional["LatentRef"] = None,
) -> "SamplerRef":
return await current_runtime().ops.apply(
"sampler.self_refine_video", latent, {
"stochastic_steps": stochastic_steps,
"certain_percentage": certain_percentage,
"uncertainty_threshold": uncertainty_threshold,
"seed": seed,
"verbose": verbose,
})
class SigmasRef(_TypedRef):
"""Opaque one-dimensional host-owned sampling schedule."""
KIND = "SIGMAS"
async def steps(self) -> int:
"""Return the number of sampling intervals in this schedule.
This exposes one bounded scalar (`len(sigmas) - 1`), not the schedule
tensor. Custom-sampler nodes need it to call the generic sampling
service without inventing a step count or materializing SIGMAS.
"""
return int(await current_runtime().ops.apply(
"sigmas.steps", self, {}))
async def value_at(self, index: int) -> float:
"""Return one finite scalar from a bounded sampling schedule."""
return float(await current_runtime().ops.apply(
"sigmas.value_at", self, {"index": int(index)}))
class WeightDiffCursorRef(_TypedRef):
"""Execution-scoped iterator over host-owned model weight differences.
Each item contains one optional ``TensorRef`` plus its host-derived output
key metadata. Advancing invalidates the previous tensor ref, so process or
materialize that tensor before requesting the next item.
"""
KIND = "WEIGHT_DIFF_CURSOR"
async def next(self) -> Optional[dict[str, Any]]:
return await current_runtime().ops.apply(
"weight_diff.next", self, {})
def __aiter__(self) -> "WeightDiffCursorRef":
return self
async def __anext__(self) -> dict[str, Any]:
item = await self.next()
if item is None:
raise StopAsyncIteration
return item
class ControlNetWeightsRef(_TypedRef):
"""Opaque Advanced-ControlNet weight policy."""
KIND = "CONTROL_NET_WEIGHTS"
@classmethod
async def from_list(
cls, weights: list[float], uncond_multiplier: float = 1.0,
extras: Any = None,
) -> tuple["ControlNetWeightsRef", "TimestepKeyframeRef"]:
result = await current_runtime().ops.apply(
"advanced_control.weights_from_list", None, {
"weights": weights,
"uncond_multiplier": uncond_multiplier,
"extras": {} if extras is None else extras,
})
return result[0], result[1]
@classmethod
async def scaled_soft(
cls, base_multiplier: float = 0.825,
uncond_multiplier: float = 1.0,
) -> tuple["ControlNetWeightsRef", "TimestepKeyframeRef"]:
result = await current_runtime().ops.apply(
"advanced_control.scaled_soft_weights", None, {
"base_multiplier": base_multiplier,
"uncond_multiplier": uncond_multiplier,
})
return result[0], result[1]
class TimestepKeyframeRef(_TypedRef):
"""Opaque Advanced-ControlNet timestep-keyframe schedule."""
KIND = "TIMESTEP_KEYFRAME"
class ModelRef(_TypedRef):
KIND = "MODEL"
# A MODEL crosses as a handle only. There is deliberately no `clone()`:
# in the original API `model.clone()` is never the last line, it exists so
# the next line can patch the copy, and a clone nothing can mutate has no
# valid second line. `patch` below is that second line, expressed as data.
async def patch(self, transform: str, **params: Any) -> "ModelRef":
"""Apply a named transform, returning a NEW model ref.
The vocabulary is closed and core-owned (`_model_transforms.py`). You
name a behaviour; the host decides what it means and does the clone.
model = await model.patch("attention_impl", mode="sage")
model = await model.patch("ffn_chunking", chunks=4)
Transforms stack — each call returns a new ref, so the second line
above does not undo the first. The return value is the whole point: a
`patch` whose result is discarded has done nothing.
This is not the original API's `set_model_attn1_patch(fn)`. That takes
a FUNCTION, and a function is code, which is the one thing that cannot
cross into the trusted process. Every transform in the table exists
because the node asking for it wanted an engine setting, not a
computation — a dropdown value, a chunk count, a bool.
"""
for key, value in params.items():
if callable(value):
raise TypeError(
f"patch({transform!r}) was given a function for {key!r}. "
f"Transforms take DATA — a choice, a number, a flag — "
f"because guest code cannot run in the host process. If "
f"the behaviour you need is not in the host's transform "
f"table, it belongs in core, not in a callback")
return await current_runtime().ops.apply(
"model.patch", self, {"transform": transform, "params": params})
async def ground_image(
self, image: ImageRef, conditioning: CondRef, *,
threshold: float = 0.5, refine_iterations: int = 2,
individual_masks: bool = True, max_detections: int = 64,
) -> tuple[MaskRef, list[list[dict[str, float]]]]:
"""Text-ground objects in an image with a compatible vision MODEL.
This is the generic intent exposed by SAM3/SAM3.1: conditioning and
pixels remain host-owned, while the caller receives bounded masks and
box data. Prompt construction and any layout policy stay pack-side.
"""
result = await current_runtime().ops.apply(
"model.ground_image", self, {
"image": image,
"conditioning": conditioning,
"threshold": float(threshold),
"refine_iterations": int(refine_iterations),
"individual_masks": individual_masks,
"max_detections": int(max_detections),
})
return result[0], result[1]
async def spatial_crop_inputs(
self, *, regions: list[tuple[int, int, int, int]],
source_width: int, source_height: int,
target_width: int, target_height: int,
) -> "ModelRef":
"""Clone the model with model-owned spatial inputs cropped to tiles.
This is a small tiling primitive, not an upscaler implementation. The
caller owns tile selection, target size, and orchestration; core only
asks each model patch that declares spatial-input support to crop its
own data.
"""
return await current_runtime().ops.apply(
"sampling.spatial_crop_inputs", self, {
"regions": regions,
"source_width": source_width,
"source_height": source_height,
"target_width": target_width,
"target_height": target_height,
})
async def transforms(self) -> list[dict]:
"""Return the transforms supported by the active host."""
return await current_runtime().ops.apply("model.transforms", self, {})
async def latent_scale_factor(self) -> float:
return float(await current_runtime().ops.apply(
"model.latent_scale_factor", self, {}))
async def is_flow(self) -> bool:
"""Whether the model uses ComfyUI's FLOW model family."""
return bool(await current_runtime().ops.apply(
"model.is_flow", self, {}))
async def family(self) -> str:
"""Return the model's canonical base family, or ``unknown``."""
return str(await current_runtime().ops.apply(
"model.family", self, {}))
async def unet_context_dim(self) -> Optional[int]:
"""Return a model's scalar UNet context dimension when published."""
result = await current_runtime().ops.apply(
"model.unet_context_dim", self, {})
return None if result is None else int(result)
async def is_zero_terminal_snr(self) -> bool:
"""Whether the model's sampling schedule uses zero terminal SNR."""
return bool(await current_runtime().ops.apply(
"model.is_zero_terminal_snr", self, {}))
async def sigma_for_percent(
self, percent: float, actual_endpoints: bool = False,
) -> float:
"""Project one sampling percentage through the model's schedule."""
return float(await current_runtime().ops.apply(
"model.sigma_for_percent", self, {
"percent": float(percent),
"actual_endpoints": bool(actual_endpoints),
}))
async def sampling_sigma_delta(
self, *, steps: int, sampler_name: str, scheduler: str,
start_step: int, end_step: int, denoise: float = 1.0,
sigma_schedule: Optional[dict] = None,
) -> float:
"""Return one bounded scheduler delta in latent-value units."""
return float(await current_runtime().ops.apply(
"model.sampling_sigma_delta", self, {
"steps": int(steps),
"sampler_name": str(sampler_name),
"scheduler": str(scheduler),
"start_step": int(start_step),
"end_step": int(end_step),
"denoise": float(denoise),
"sigma_schedule": sigma_schedule,
}))
async def scheduled_cfg_guider(
self, positive: "CondRef", negative: "CondRef", cfg: float,
start_percent: float = 0.0, end_percent: float = 1.0, *,
bounds: Optional[dict] = None,
) -> "GuiderRef":
return await current_runtime().ops.apply(
"guider.scheduled_cfg", self, {
"positive": positive,
"negative": negative,
"cfg": cfg,
"start_percent": start_percent,
"end_percent": end_percent,
"bounds": bounds,
})
async def lora_weight_differences(
self, original: "ModelRef", include_bias: bool = False,
) -> "WeightDiffCursorRef":
return await current_runtime().ops.apply(
"lora.weight_differences", self, {
"original": original,
"include_bias": bool(include_bias),
})
async def apply_lora(
self, asset: "AssetRef", clip: Optional["ClipRef"],
strength_model: float, strength_clip: float,
) -> tuple["ModelRef", Optional["ClipRef"]]:
"""Apply one resolved LoRA without exposing model weights or paths.
``asset`` must have been resolved from the host's ``loras`` catalogue.
The trusted implementation confines its path again before loading it.
"""
result = await current_runtime().ops.apply(
"model.apply_lora", self, {
"asset": asset,
"clip": clip,
"strength_model": strength_model,
"strength_clip": strength_clip,
})
return result[0], result[1]
async def apply_dit_block_lora(
self, asset: "AssetRef", strength_model: float,
block_weights: list[dict[str, Any]],
) -> tuple["ModelRef", str]:
result = await current_runtime().ops.apply(
"model.apply_dit_block_lora", self, {
"asset": asset,
"strength_model": strength_model,
"block_weights": block_weights,
})
return result[0], str(result[1])
async def apply_ltx2_lora(
self, asset: "AssetRef", strength_model: float,
block_weights: list[dict[str, Any]], video: float,
video_to_audio: float, audio: float, audio_to_video: float,
other: float,
) -> tuple["ModelRef", str, str]:
result = await current_runtime().ops.apply(
"model.apply_ltx2_lora", self, {
"asset": asset,
"strength_model": strength_model,
"block_weights": block_weights,
"video": video,
"video_to_audio": video_to_audio,
"audio": audio,
"audio_to_video": audio_to_video,
"other": other,
})
return result[0], str(result[1]), str(result[2])
class ClipRef(_TypedRef):
KIND = "CLIP"
async def set_last_layer(self, stop_at_clip_layer: int) -> "ClipRef":
"""Clone this CLIP and stop encoding at a bounded hidden layer."""
return await current_runtime().ops.apply(
"clip.set_last_layer", self,
{"stop_at_clip_layer": int(stop_at_clip_layer)},
)
async def with_attention_impl(self, mode: str) -> "ClipRef":
"""Clone this encoder with one host-registered attention function."""
return await current_runtime().ops.apply(
"clip.with_attention_impl", self, {"mode": str(mode)})
async def describe_tokens(self, tokens: dict) -> dict:
"""Describe bounded token IDs without exposing tokenizer objects.
The result mirrors the token component/chunk structure. Each entry is
``{"id": int, "text": str, "special": bool}``; token weights stay
in the caller's original token dictionary.
"""
return await current_runtime().ops.apply(
"clip.describe_tokens", self, {"tokens": tokens})
async def scale_attention_weights(
self, *, clip_l: Optional[list[float]] = None,
clip_g: Optional[list[float]] = None,
t5xxl: Optional[list[float]] = None,
query: bool = True, key: bool = True,
value: bool = True, output: bool = True,
) -> "ClipRef":
"""Scale selected CLIP/T5 attention projection weights.
The guest supplies only bounded per-layer numbers and four projection
switches. State-dict discovery and patching stay on the trusted plane;
neither weights nor arbitrary key patterns cross the boundary.
"""
return await current_runtime().ops.apply(
"clip.scale_attention_weights", self, {
"clip_l": clip_l,
"clip_g": clip_g,
"t5xxl": t5xxl,
"query": bool(query),
"key": bool(key),
"value": bool(value),
"output": bool(output),
})
async def tokenize(self, text: str, **kwargs: Any) -> dict:
"""Mirrors ``clip.tokenize``, including its per-model kwargs.
Tokenizing is a separate step from encoding because real nodes work
between the two. ``CLIPTextEncodeSDXL`` builds one token dict from two
prompts (``tokens["l"] = clip.tokenize(text_l)["l"]``) and pads the
halves to equal length; the ACE nodes pass lyrics, bpm, duration and
more as tokenizer kwargs. A combined call alone cannot express either.
Tokens are plain data and cross the wire as themselves. Where a model's
tokenizer returns tensors (image-conditioned encoders do), this raises
at the wire rather than silently degrading.
"""
return await current_runtime().ops.apply("clip.tokenize", self,
{"text": text, "kwargs": kwargs})
async def encode_from_tokens_scheduled(self, tokens: dict,
add_dict: dict | None = None) -> "CondRef":
"""Mirrors ``clip.encode_from_tokens_scheduled``, ``add_dict`` included.
``add_dict`` carries the conditioning extras every SDXL-family encoder
sets — width, height, crop, target size — so it is part of the call, not
an optional extra.
"""
return await current_runtime().ops.apply(
"clip.encode_from_tokens_scheduled", self,
{"tokens": tokens, "add_dict": add_dict})
async def encode_token_weights_component(
self, component: str, tokens: list,
) -> tuple[TensorRef, Optional[TensorRef]]:
"""Encode token-weight pairs with one named CLIP component.
This is the narrow seam needed by prompt-weight algorithms which do
their own embedding arithmetic. The guest receives embeddings, never
text-encoder modules or weights. ``component`` is limited to the
conventional ``l`` and ``g`` encoders used by SD1/SDXL.
"""
result = await current_runtime().ops.apply(
"clip.encode_token_weights_component", self,
{"component": str(component), "tokens": tokens},
)
return result[0], result[1]
async def encode(self, text: str) -> "CondRef":
"""The two steps above in one call, for the common case.
Exactly what ``CLIPTextEncode`` does, and it saves a wire round trip.
A convenience over the pair, not a replacement: anything that inspects
or edits tokens uses ``tokenize`` + ``encode_from_tokens_scheduled``.
"""
return await current_runtime().ops.apply("clip.encode", self,
{"text": text})
async def generate_text(
self, prompt: str, image: Optional[ImageRef] = None,
video: Optional[ImageRef] = None,
max_length: int = 256, do_sample: bool = False,
temperature: float = 1.0, top_k: Optional[int] = 50,
top_p: float = 0.95, min_p: float = 0.0,
repetition_penalty: float = 1.0, seed: Optional[int] = None,
presence_penalty: float = 0.0, thinking: bool = False,
use_default_template: bool = True, num_beams: int = 1,
) -> str:
"""Generate bounded text with a canonical Comfy text encoder.
Image-conditioned token dictionaries may contain host tensors, so the
tokenize/generate/decode sequence stays one opaque operation. Prompt
construction and higher-level captioning policy remain pack-side.
"""
return str(await current_runtime().ops.apply(
"clip.generate_text", self, {
"prompt": str(prompt),
"image": image,
"video": video,
"max_length": int(max_length),
"do_sample": bool(do_sample),
"temperature": float(temperature),
"top_k": None if top_k is None else int(top_k),
"top_p": float(top_p),
"min_p": float(min_p),
"repetition_penalty": float(repetition_penalty),
"seed": seed,
"presence_penalty": float(presence_penalty),
"thinking": bool(thinking),
"use_default_template": bool(use_default_template),
"num_beams": int(num_beams),
}))
async def lora_weight_differences(
self, original: "ClipRef", include_bias: bool = False,
) -> "WeightDiffCursorRef":
return await current_runtime().ops.apply(
"lora.weight_differences", self, {
"original": original,
"include_bias": bool(include_bias),
})
class LlamaCppModelRef(_TypedRef):
"""Opaque vendor-owned llama.cpp chat/VLM session."""
KIND = "LLAMA_CPP_MODEL"
async def generate(
self, system: str, prompt: str,
image: Optional[ImageRef] = None,
video: Optional[ImageRef] = None,
max_tokens: int = 512,
temperature: float = 0.7,
top_p: float = 0.9,
repetition_penalty: float = 1.0,
seed: int = 1,
) -> str:
return str(await current_context().integrations.llama_cpp.generate(
self,
system=system,
prompt=prompt,
image=image,
video=video,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
repetition_penalty=repetition_penalty,
seed=seed,
))
class GligenRef(_TypedRef):
KIND = "GLIGEN"
async def apply_batched(
self, conditioning: "CondRef", clip: "ClipRef", text: str,
boxes: list[tuple[int, int, int | float, int | float]],
) -> "CondRef":
return await current_runtime().ops.apply(
"gligen.apply_batched", self, {
"conditioning": conditioning,
"clip": clip,
"text": text,
"boxes": boxes,
})
class VaeRef(_TypedRef):
KIND = "VAE"
async def latent_layout(self) -> dict[str, Optional[int]]:
"""Return the VAE's bounded latent channel/compression metadata."""
return dict(await current_runtime().ops.apply(
"vae.latent_layout", self, {}))
async def decode(self, latent: "LatentRef") -> "ImageRef":
return await current_runtime().ops.apply("vae.decode", self,
{"latent": latent})
async def decode_tensor(self, latent: "LatentRef") -> "TensorRef":
"""Decode to an opaque BHWC tensor without assuming RGB channels.
This is the raw-tier counterpart to :meth:`decode`. It exists for
pack-owned post-processing of VAEs whose decoded representation is not
yet an IMAGE (for example, a channel-packed upscale VAE). The model
and decode stay host-owned; reading the returned tensor still requires
the ordinary ``raw`` capability.
"""
return await current_runtime().ops.apply(
"vae.decode_tensor", self, {"latent": latent})
async def decode_tiled(
self, latent: "LatentRef", tile_size: int = 512,
overlap: int = 64, temporal_size: int = 64,
temporal_overlap: int = 8,
) -> "ImageRef":
"""Decode through ComfyUI's bounded, pixel-sized tiled operation.
This mirrors the intent of core's ``VAEDecodeTiled`` node: spatial and
temporal compression are derived from the host VAE, so a guest never
needs to assume a latent compression ratio. Model state and execution
remain on the trusted plane; the guest receives only an image handle.
"""
return await current_runtime().ops.apply("vae.decode_tiled", self, {
"latent": latent,
"tile_size": int(tile_size),
"overlap": int(overlap),
"temporal_size": int(temporal_size),
"temporal_overlap": int(temporal_overlap),
})
async def decode_tensor_tiled(
self, latent: "LatentRef", tile_size: int = 512,
overlap: int = 64, temporal_size: int = 64,
temporal_overlap: int = 8,
) -> "TensorRef":
"""Tiled :meth:`decode_tensor` with the canonical VAE tile options."""
return await current_runtime().ops.apply(
"vae.decode_tensor_tiled", self, {
"latent": latent,
"tile_size": int(tile_size),
"overlap": int(overlap),
"temporal_size": int(temporal_size),
"temporal_overlap": int(temporal_overlap),
})
async def encode(self, image: "ImageRef") -> "LatentRef":
"""Mirrors ``vae.encode`` exactly. The caller owns any channel slicing.
Pixels pass through untouched, matching core's ``VAEEncode``
(``t = vae.encode(pixels)``). Slicing here would change results rather
than shape — silently dropping alpha for a four-channel caller — and be
invisible from the calling node's own source.
"""
return await current_runtime().ops.apply("vae.encode", self,
{"image": image})
async def encode_for_inpaint(
self, image: "ImageRef", mask: "MaskRef", grow_mask_by: int = 6,
) -> "LatentRef":
"""Run core VAEEncodeForInpaint with a bounded mask grow amount."""
return await current_runtime().ops.apply(
"vae.encode_for_inpaint", self, {
"image": image,
"mask": mask,
"grow_mask_by": int(grow_mask_by),
})
async def encode_inpaint_conditioning(
self, image: "ImageRef", mask: "MaskRef",
positive: "CondRef", negative: "CondRef",
noise_mask: bool = True,
) -> tuple["CondRef", "CondRef", "LatentRef"]:
"""Run core InpaintModelConditioning without exposing model tensors."""
result = await current_runtime().ops.apply(
"vae.encode_inpaint_conditioning", self, {
"image": image,
"mask": mask,
"positive": positive,
"negative": negative,
"noise_mask": bool(noise_mask),
})
return result[0], result[1], result[2]
async def encode_tiled(
self, image: "ImageRef", tile_x: Optional[int] = None,
tile_y: Optional[int] = None, overlap: Optional[int] = None,
tile_t: Optional[int] = None, overlap_t: Optional[int] = None,
) -> "LatentRef":
return await current_runtime().ops.apply("vae.encode_tiled", self, {
"image": image,
"tile_x": tile_x,
"tile_y": tile_y,
"overlap": overlap,
"tile_t": tile_t,
"overlap_t": overlap_t,
})
async def input_dtype(self) -> str:
"""The CPU dtype a node should use before VAE-specific preprocessing."""
return str(await current_runtime().ops.apply(
"vae.input_dtype", self, {}))
async def encode_video(
self, image: "ImageRef",
) -> tuple["LatentRef", int]:
"""Encode frames after applying this VAE's temporal frame constraint."""
result = await current_runtime().ops.apply(
"vae.encode_video", self, {"image": image})
return result[0], int(result[1])
async def decode_video(
self, latent: "LatentRef", *, tiled: bool = False,
tile_size: int = 512, overlap: int = 64,
temporal_size: int = 4096, temporal_overlap: int = 16,
) -> "ImageRef":
"""Decode image/video latents and flatten video batches to frames."""
return await current_runtime().ops.apply("vae.decode_video", self, {
"latent": latent,
"tiled": bool(tiled),
"tile_size": int(tile_size),
"overlap": int(overlap),
"temporal_size": int(temporal_size),
"temporal_overlap": int(temporal_overlap),
})
async def decode_audio(self, latent: "LatentRef") -> "AudioRef":
"""Decode an audio latent and attach the VAE's output sample rate."""
return await current_runtime().ops.apply(
"vae.decode_audio", self, {"latent": latent})
async def downscale_index_formula(self) -> Optional[tuple[int, int, int]]:
value = await current_runtime().ops.apply(
"vae.downscale_index_formula", self, {})
if value is None:
return None
return tuple(int(item) for item in value)
async def merge(self, other: "VaeRef", ratio: float = 0.5) -> "VaeRef":
return await current_runtime().ops.apply(
"vae.merge", self, {"other": other, "ratio": float(ratio)})
async def compile(
self, *, backend: str = "inductor", mode: str = "default",
fullgraph: bool = False, encoder: bool = True, decoder: bool = True,
) -> "VaeRef":
return await current_runtime().ops.apply("vae.compile", self, {
"backend": backend,
"mode": mode,
"fullgraph": fullgraph,
"encoder": encoder,
"decoder": decoder,
})
async def patch_triton(
self, *, fuse_norm_silu: bool = True, channels_last: bool = True,
int8_conv: bool = False, autotune: bool = False,
) -> "VaeRef":
return await current_runtime().ops.apply("vae.patch_triton", self, {
"fuse_norm_silu": fuse_norm_silu,
"channels_last": channels_last,
"int8_conv": int8_conv,
"autotune": autotune,
})
class ClipVisionOutputRef(_TypedRef):
KIND = "CLIP_VISION_OUTPUT"
async def concat(
self, other: "ClipVisionOutputRef",
) -> "ClipVisionOutputRef":
"""Concatenate opaque penultimate vision tokens along their token axis."""
return await current_runtime().ops.apply(
"clip_vision_output.concat", self, {"other": other})
async def image_embeds(self) -> TensorRef:
return await current_runtime().ops.apply(
"clip_vision_output.image_embeds", self, {})
class ClipVisionRef(_TypedRef):
KIND = "CLIP_VISION"
async def encode_image(
self, image: ImageRef, crop: bool = True,
) -> ClipVisionOutputRef:
return await current_runtime().ops.apply(
"clip_vision.encode_image", self,
{"image": image, "crop": bool(crop)})
class ControlNetRef(_TypedRef):
KIND = "CONTROL_NET"
async def apply(
self, positive: CondRef, negative: CondRef, image: ImageRef,
strength: float = 1.0, start_percent: float = 0.0,
end_percent: float = 1.0, vae: Optional[VaeRef] = None,
) -> tuple[CondRef, CondRef]:
"""Apply this ControlNet while all referenced data stays host-owned."""
return await current_runtime().ops.apply(
"controlnet.apply", self, {
"positive": positive,
"negative": negative,
"image": image,
"strength": strength,
"start_percent": start_percent,
"end_percent": end_percent,
"vae": vae,
})
async def apply_advanced(
self, positive: CondRef, negative: CondRef, image: ImageRef,
strength: float = 1.0, start_percent: float = 0.0,
end_percent: float = 1.0, vae: Optional[VaeRef] = None,
mask: Optional[MaskRef] = None,
timestep_keyframe: Optional[TimestepKeyframeRef] = None,
weights: Optional[ControlNetWeightsRef] = None,
) -> tuple[CondRef, CondRef]:
"""Apply Advanced-ControlNet scheduling, weights, and effect masks."""
return await current_runtime().ops.apply(
"controlnet.apply_advanced", self, {
"positive": positive,
"negative": negative,
"image": image,
"strength": strength,
"start_percent": start_percent,
"end_percent": end_percent,
"vae": vae,
"mask": mask,
"timestep_keyframe": timestep_keyframe,
"weights": weights,
})
async def with_union_type(self, type_number: Optional[int]) -> "ControlNetRef":
return await current_runtime().ops.apply(
"controlnet.with_union_type", self,
{"type_number": type_number})
async def compile(
self, *, backend: str = "inductor", mode: str = "default",
fullgraph: bool = False,
) -> "ControlNetRef":
return await current_runtime().ops.apply("controlnet.compile", self, {
"backend": backend,
"mode": mode,
"fullgraph": fullgraph,
})
class StyleModelRef(_TypedRef):
KIND = "STYLE_MODEL"
async def apply(
self, clip_vision_output: ClipVisionOutputRef,
conditioning: CondRef, strength: float = 1.0,
) -> CondRef:
return await current_runtime().ops.apply(
"style_model.apply", self, {
"clip_vision_output": clip_vision_output,
"conditioning": conditioning,
"strength": float(strength),
})
class ClipSegRef(_TypedRef):
KIND = "CLIPSEGMODEL"
async def predict_mask(
self, images: ImageRef, text: str,
use_accelerator: bool = True,
) -> MaskRef:
"""Return native-resolution sigmoid CLIPSeg predictions.
This is the narrow inference primitive for nodes that own their own
thresholding and morphology. Model weights remain host-side.
"""
return await current_runtime().ops.apply(
"clipseg.predict_mask", self, {
"images": images,
"text": str(text),
"use_accelerator": bool(use_accelerator),
})
async def segment(
self, images: ImageRef, text: str, threshold: float = 0.5,
binary_mask: bool = True, combine_mask: bool = False,
use_accelerator: bool = True, blur_sigma: float = 0.0,
previous_mask: Optional[MaskRef] = None, invert: bool = False,
image_background_level: float = 0.5,
) -> tuple[MaskRef, ImageRef]:
result = await current_runtime().ops.apply(
"clipseg.segment", self, {
"images": images,
"text": str(text),
"threshold": float(threshold),
"binary_mask": bool(binary_mask),
"combine_mask": bool(combine_mask),
"use_accelerator": bool(use_accelerator),
"blur_sigma": float(blur_sigma),
"previous_mask": previous_mask,
"invert": bool(invert),
"image_background_level": float(image_background_level),
})
return result[0], result[1]
class ImageClassifierRef(_TypedRef):
KIND = "IMAGE_CLASSIFIER"
async def classify(
self, images: ImageRef, use_accelerator: bool = True,
top_k: int = 5,
) -> list[list[dict[str, Any]]]:
"""Classify a host-side image batch and return bounded label scores."""
return await current_runtime().ops.apply(
"image_classifier.classify", self, {
"images": images,
"use_accelerator": bool(use_accelerator),
"top_k": int(top_k),
})
async def predict_scores(
self, images: ImageRef,
) -> "ClassifierScoresRef":
"""Run a multi-label classifier and retain its score matrix host-side."""
return await current_runtime().ops.apply(
"image_classifier.predict_scores", self, {"images": images})
class ClassifierScoresRef(_TypedRef):
"""Opaque bounded batch-by-class scores from an image classifier."""
KIND = "CLASSIFIER_SCORES"
async def shape(self) -> tuple[int, int]:
result = await current_runtime().ops.apply(
"classifier_scores.shape", self, {})
return int(result[0]), int(result[1])
async def select_above(
self, batch_index: int, start: int, end: int, threshold: float,
offset: int = 0, limit: int = 512,
) -> dict[str, Any]:
"""Page score/index pairs above a threshold in one class range."""
return await current_runtime().ops.apply(
"classifier_scores.select_above", self, {
"batch_index": int(batch_index),
"start": int(start),
"end": int(end),
"threshold": float(threshold),
"offset": int(offset),
"limit": int(limit),
})
class SemanticSegmentationRef(_TypedRef):
"""Opaque fixed-architecture semantic segmentation model."""
KIND = "SEMANTIC_SEGMENTATION_MODEL"
async def mask(
self, image: ImageRef, classes: list[int],
) -> MaskRef:
"""Return the union of selected semantic class IDs as a mask."""
return await current_runtime().ops.apply(
"semantic_segmentation.mask", self, {
"image": image,
"classes": list(classes),
})
class MattingModelRef(_TypedRef):
"""Opaque fixed-architecture image matting model."""
KIND = "MATTING_MODEL"
async def refine(
self, image: ImageRef, trimap: MaskRef,
max_megapixels: float = 2.0,
) -> MaskRef:
"""Refine a coarse trimap into an alpha mask."""
return await current_runtime().ops.apply(
"matting.refine", self, {
"image": image,
"trimap": trimap,
"max_megapixels": float(max_megapixels),
})
class VqaModelRef(_TypedRef):
"""Opaque visual question-answering model."""
KIND = "VQA_MODEL"
async def answer(
self, image: ImageRef, question: str,
max_new_tokens: int = 32,
) -> str:
return str(await current_runtime().ops.apply(
"vqa.answer", self, {
"image": image,
"question": str(question),
"max_new_tokens": int(max_new_tokens),
}))
class OnnxDetectorRef(_TypedRef):
KIND = "ONNX_DETECTOR"
async def detect(self, image: ImageRef) -> list[dict[str, Any]]:
"""Run a catalogued ONNX object detector on one host-side image."""
return await current_runtime().ops.apply(
"onnx_detector.detect", self, {"image": image})
class ObjectDetectorRef(_TypedRef):
KIND = "OBJECT_DETECTOR"
async def detect(
self, image: ImageRef, threshold: float = 0.5,
class_name: str = "all", max_detections: int = 100,
) -> list[list[dict[str, Any]]]:
return await current_runtime().ops.apply(
"object_detector.detect", self, {
"image": image,
"threshold": float(threshold),
"class_name": str(class_name),
"max_detections": int(max_detections),
})
class ImagePreprocessorRef(_TypedRef):
"""Opaque host-created image preprocessor with one bounded operation."""
KIND = "IMAGE_PREPROCESSOR"
async def apply(
self, image: ImageRef, mask: Optional[MaskRef] = None,
) -> ImageRef:
return await current_runtime().ops.apply(
"image_preprocessor.apply", self, {
"image": image,
"mask": mask,
})
class InpaintModelRef(_TypedRef):
"""Opaque prompt-free image inpainting model."""
KIND = "INPAINT_MODEL"
async def inpaint(
self, image: ImageRef, mask: MaskRef,
) -> ImageRef:
"""Fill the masked image region while keeping model weights host-side."""
return await current_runtime().ops.apply(
"inpaint_model.inpaint", self, {
"image": image,
"mask": mask,
})
class BackgroundRemovalModelRef(_TypedRef):
"""Opaque ComfyUI background-removal model."""
KIND = "BACKGROUND_REMOVAL_MODEL"
async def mask(self, image: ImageRef) -> MaskRef:
"""Generate a foreground alpha mask through core's canonical model."""
return await current_runtime().ops.apply(
"background_removal.mask", self, {"image": image})
class BrushNetRef(_TypedRef):
"""Opaque BrushNet weights loaded by the canonical host extension.
The pack keeps ownership of its pipeline dictionary and orchestration. A
guest can only ask the host to apply the already-loaded model to typed
inputs; neither the live BrushNet object nor its tensors cross the wire.
"""
KIND = "BRUSHNET_MODEL"
async def apply(
self, model: ModelRef, vae: VaeRef, image: ImageRef, mask: MaskRef,
positive: CondRef, negative: CondRef, scale: float = 1.0,
start_step: int = 0, end_step: int = 10000,
) -> tuple[ModelRef, CondRef, CondRef, LatentRef]:
return await current_runtime().ops.apply(
"brushnet.apply", self, {
"model": model,
"vae": vae,
"image": image,
"mask": mask,
"positive": positive,
"negative": negative,
"scale": float(scale),
"start_step": int(start_step),
"end_step": int(end_step),
})
class PowerPaintRef(_TypedRef):
"""Opaque PowerPaint model and token-extended CLIP pipeline."""
KIND = "POWERPAINT_MODEL"
async def apply(
self, model: ModelRef, vae: VaeRef, image: ImageRef, mask: MaskRef,
positive: CondRef, negative: CondRef, fitting: float = 1.0,
function: str = "text guided", scale: float = 1.0,
start_step: int = 0, end_step: int = 10000,
save_memory: str = "none",
) -> tuple[ModelRef, CondRef, CondRef, LatentRef]:
return await current_runtime().ops.apply(
"powerpaint.apply", self, {
"model": model,
"vae": vae,
"image": image,
"mask": mask,
"positive": positive,
"negative": negative,
"fitting": float(fitting),
"function": str(function),
"scale": float(scale),
"start_step": int(start_step),
"end_step": int(end_step),
"save_memory": str(save_memory),
})
class TransparentVaeDecoderRef(_TypedRef):
"""Opaque canonical decoder for Layer Diffusion transparency weights."""
KIND = "TRANSPARENT_VAE_DECODER"
async def decode(
self, latent: LatentRef, image: ImageRef, frames: int = 1,
sub_batch_size: int = 16,
) -> tuple[ImageRef, MaskRef]:
"""Return a consistent RGBA batch and decoded alpha masks.
For interleaved multi-frame Layer Diffusion batches, only the first
frame in each group is transparency-decoded; the other RGB frames are
returned with an opaque alpha channel.
"""
result = await current_runtime().ops.apply(
"transparent_vae_decoder.decode", self, {
"latent": latent,
"image": image,
"frames": int(frames),
"sub_batch_size": int(sub_batch_size),
})
return result[0], result[1]
class IpAdapterEmbedsRef(_TypedRef):
"""Opaque image embeddings produced by a host IP-Adapter encoder."""
KIND = "IPADAPTER_EMBEDS"
async def combine(
self,
others: list["IpAdapterEmbedsRef"],
method: str = "concat",
) -> "IpAdapterEmbedsRef":
return await current_runtime().ops.apply(
"ipadapter_embeds.combine", self, {
"others": list(others),
"method": str(method),
})
class IpAdapterRef(_TypedRef):
"""Opaque, host-created IP-Adapter pipeline.
The pipeline's models and loader objects remain in the trusted process.
Guest code may only request the fixed model-application operation below;
crop selection and pack-specific orchestration stay in the guest.
"""
KIND = "IPADAPTER_PIPE"
async def apply(
self,
model: ModelRef,
image: ImageRef,
negative_image: Optional[ImageRef] = None,
attn_mask: Optional[MaskRef] = None,
style_image: Optional[ImageRef] = None,
composition_image: Optional[ImageRef] = None,
weight: float = 0.7,
weight_type: str = "channel penalty",
start_percent: float = 0.0,
end_percent: float = 1.0,
combine_embeds: str = "concat",
weight_faceidv2: float = 1.0,
embeds_scaling: str = "V only",
unfold_batch: bool = False,
layer_weights: Optional[str] = None,
weight_style: float = 1.0,
weight_composition: float = 1.0,
expand_style: bool = False,
) -> ModelRef:
"""Apply this pipeline to a model using bounded image inputs."""
return await current_runtime().ops.apply(
"ipadapter.apply", self, {
"model": model,
"image": image,
"negative_image": negative_image,
"attn_mask": attn_mask,
"style_image": style_image,
"composition_image": composition_image,
"weight": float(weight),
"weight_type": str(weight_type),
"start_percent": float(start_percent),
"end_percent": float(end_percent),
"combine_embeds": str(combine_embeds),
"weight_faceidv2": float(weight_faceidv2),
"embeds_scaling": str(embeds_scaling),
"unfold_batch": bool(unfold_batch),
"layer_weights": layer_weights,
"weight_style": float(weight_style),
"weight_composition": float(weight_composition),
"expand_style": bool(expand_style),
})
async def apply_tiled(
self,
model: ModelRef,
image: ImageRef,
negative_image: Optional[ImageRef] = None,
attn_mask: Optional[MaskRef] = None,
weight: float = 0.7,
weight_type: str = "linear",
start_percent: float = 0.0,
end_percent: float = 1.0,
combine_embeds: str = "concat",
embeds_scaling: str = "V only",
sharpening: float = 0.0,
unfold_batch: bool = False,
) -> tuple[ModelRef, ImageRef, MaskRef]:
"""Apply the canonical tiled IP-Adapter operation."""
result = await current_runtime().ops.apply(
"ipadapter.apply_tiled", self, {
"model": model,
"image": image,
"negative_image": negative_image,
"attn_mask": attn_mask,
"weight": float(weight),
"weight_type": str(weight_type),
"start_percent": float(start_percent),
"end_percent": float(end_percent),
"combine_embeds": str(combine_embeds),
"embeds_scaling": str(embeds_scaling),
"sharpening": float(sharpening),
"unfold_batch": bool(unfold_batch),
})
return result[0], result[1], result[2]
async def encode(
self,
image: ImageRef,
weight: float = 1.0,
mask: Optional[MaskRef] = None,
) -> tuple[IpAdapterEmbedsRef, IpAdapterEmbedsRef]:
"""Encode one image into positive and negative IP-Adapter embeddings."""
result = await current_runtime().ops.apply(
"ipadapter.encode", self, {
"image": image,
"weight": float(weight),
"mask": mask,
})
return result[0], result[1]
async def apply_embeds(
self,
model: ModelRef,
positive: IpAdapterEmbedsRef,
negative: Optional[IpAdapterEmbedsRef] = None,
attn_mask: Optional[MaskRef] = None,
weight: float = 1.0,
weight_type: str = "linear",
start_percent: float = 0.0,
end_percent: float = 1.0,
embeds_scaling: str = "V only",
) -> ModelRef:
"""Apply already encoded image embeddings to a model."""
return await current_runtime().ops.apply(
"ipadapter.apply_embeds", self, {
"model": model,
"positive": positive,
"negative": negative,
"attn_mask": attn_mask,
"weight": float(weight),
"weight_type": str(weight_type),
"start_percent": float(start_percent),
"end_percent": float(end_percent),
"embeds_scaling": str(embeds_scaling),
})
class SamModelRef(_TypedRef):
KIND = "SAM_MODEL"
async def segment(
self,
image: ImageRef,
boxes: list[Optional[list[float]]],
point_coords: Optional[list[list[list[float]]]] = None,
point_labels: Optional[list[list[int]]] = None,
multimask_output: bool = True,
) -> tuple[MaskRef, list[list[float]]]:
"""Segment one host-side image from bounded boxes and point hints.
The returned mask tensor is QxMxHxW, where Q is the query count and M
is the number of masks per query. Model weights and predictor objects
never enter the guest.
"""
result = await current_runtime().ops.apply(
"sam.segment", self, {
"image": image,
"boxes": boxes,
"point_coords": point_coords,
"point_labels": point_labels,
"multimask_output": bool(multimask_output),
})
return result[0], result[1]
async def segment_video(
self, frames: ImageRef, boxes: list[list[float]],
) -> MaskRef:
"""Propagate frame-zero boxes through a host-side SAM2 video batch.
The returned mask logits are QxFxHxW. Model state and predictor
internals remain on the trusted plane; callers own thresholding and
conversion into their pack-specific segment representation.
"""
return await current_runtime().ops.apply(
"sam.segment_video", self, {
"frames": frames,
"boxes": boxes,
})
class UpscaleModelRef(_TypedRef):
KIND = "UPSCALE_MODEL"
async def upscale(
self, images: ImageRef, per_batch: int = 16,
downscale_ratio: float = 1.0, downscale_method: str = "lanczos",
precision: str = "float32", tile_size: Optional[int] = None,
channels_last: bool = False,
) -> ImageRef:
return await current_runtime().ops.apply(
"upscale_model.upscale", self, {
"images": images,
"per_batch": int(per_batch),
"downscale_ratio": float(downscale_ratio),
"downscale_method": str(downscale_method),
"precision": str(precision),
"tile_size": None if tile_size is None else int(tile_size),
"channels_last": bool(channels_last),
})
class AudioRef(ValueRef):
KIND = "AUDIO"
class VideoRef(_TypedRef):
KIND = "VIDEO"
async def encoded_source(self) -> ValueRef:
"""Return encoded bytes and trim metadata, never the source path."""
return await current_runtime().ops.apply(
"video.encoded_source", self, {})
class AssetRef(_TypedRef):
"""A file/model resolved by name+hash, tenant-scoped. Never a raw path."""
KIND = "ASSET"
class OpaqueRef(_TypedRef):
"""A pass-through handle for a value with no sandbox materializer."""
KIND = "OPAQUE"
# --------------------------------------------------------------------------- #
# Runtime — per-execution binding of (ref resolver, ctx), set by the active
# ExecutionBackend. Refs read it via a contextvar, mirroring how the engine
# exposes get_executing_context(). Authors never construct it.
# --------------------------------------------------------------------------- #
@dataclass
class Runtime:
refs: "RefResolver"
ctx: "Context"
ops: "OpsProvider" = None # engine-side operations (the preferred interface)
_active_runtime: "contextvars.ContextVar[Optional[Runtime]]" = contextvars.ContextVar(
"comfy_sdk_runtime", default=None
)
def current_runtime() -> Runtime:
rt = _active_runtime.get()
if rt is None:
raise RuntimeError(
"No active Comfy SDK runtime. Ref/ctx access is only valid inside "
"node execution."
)
return rt
def current_context() -> "Context":
return current_runtime().ctx
class _RuntimeScope:
"""Context manager the ExecutionBackend uses to bind a runtime per node."""
def __init__(self, runtime: Runtime) -> None:
self._runtime = runtime
self._token: Any = None
def __enter__(self) -> Runtime:
self._token = _active_runtime.set(self._runtime)
return self._runtime
def __exit__(self, *exc: Any) -> None:
_active_runtime.reset(self._token)
@property
def runtime(self) -> Runtime:
return self._runtime
def bind_runtime(
refs: "RefResolver", ctx: "Context", ops: "OpsProvider" = None
) -> _RuntimeScope:
return _RuntimeScope(Runtime(refs=refs, ctx=ctx, ops=ops))
# --------------------------------------------------------------------------- #
# Provider interfaces. OSS ships the defaults below; the overlay overrides.
# --------------------------------------------------------------------------- #
@runtime_checkable
class RefResolver(Protocol):
async def create(self, kind: str, obj: Any) -> Ref: ...
async def resolve(self, ref: Ref) -> Any: ...
async def release(self, ref: Ref) -> None: ...
_V2_NODE_METHODS = frozenset({
"execute",
"validate_inputs",
"fingerprint_inputs",
"check_lazy_status",
})
_V2_NODE_METHOD_ALIASES = {
"EXECUTE_NORMALIZED": "execute",
"EXECUTE_NORMALIZED_ASYNC": "execute",
}
@dataclass(frozen=True)
class HuggingFaceWeight:
"""A public Hugging Face weight file required by a node.
Nodes declare these in ``SDK_REQUIRED_WEIGHTS``. The secure host reviews
the declaration from the sealed pack manifest and installs the file before
``execute`` runs. An ``on_demand`` declaration is instead an allowlisted
conditional dependency which the node explicitly requests when selected.
``catalogue_name`` is the stable name to pass to model loaders; it is never
a filesystem path.
"""
repo_id: str
filename: str
folder: str
revision: str = "main"
sha256: Optional[str] = None
on_demand: bool = False
def __post_init__(self) -> None:
repo_id = _InProcessModels._hf_repo_id(self.repo_id)
filename, extension = _InProcessModels._hf_weight_filename(
self.filename)
revision = _InProcessModels._hf_revision(self.revision)
sha256 = _InProcessModels._hf_sha256(self.sha256)
if extension == ".onnx" and sha256 is None:
raise ValueError("Hugging Face ONNX weights require a sha256 pin")
if type(self.on_demand) is not bool:
raise TypeError("Hugging Face weight on_demand must be a bool")
if not isinstance(self.folder, str):
raise TypeError("Hugging Face weight folder must be a string")
if self.folder not in _InProcessModels._HF_WEIGHT_FOLDERS:
raise ValueError(
"Hugging Face weights must target a known model catalogue")
object.__setattr__(self, "repo_id", repo_id)
object.__setattr__(self, "filename", filename)
object.__setattr__(self, "revision", revision)
object.__setattr__(self, "sha256", sha256)
@property
def catalogue_name(self) -> str:
return (
f"huggingface/{self.repo_id}/{self.revision}/{self.filename}"
)
def _normalize_v2_node_method(method: str) -> str:
method = _V2_NODE_METHOD_ALIASES.get(method, method)
if method not in _V2_NODE_METHODS:
raise ValueError(
f"V2 node method {method!r} is not allowed; "
f"expected one of {sorted(_V2_NODE_METHODS)}")
return method
@dataclass
class ExecutionPlan:
"""What the execution seam hands the backend to decide placement."""
prompt_id: str
node_id: str
node_type: str
tier: str = "default" # overlay reads manifest tier; OSS is always "default"
permissions: tuple[str, ...] = ()
required_weights: tuple[HuggingFaceWeight, ...] = ()
# Work-unit payload for out-of-process backends. ``refs`` means the node
# explicitly consumes SDK handles; ``values`` means the backend may wrap
# for transport and the guest must materialize those handles before
# invoking the unchanged V2 body. In-process dispatch ignores the payload.
node_module: str = ""
inputs: Optional[dict] = None
input_mode: str = "refs"
prompt: Any = None
extra_pnginfo: Any = None
dynamic_prompt: Any = None
method: str = "execute"
def __post_init__(self) -> None:
self.method = _normalize_v2_node_method(self.method)
self.required_weights = tuple(self.required_weights or ())
if not all(isinstance(item, HuggingFaceWeight)
for item in self.required_weights):
raise TypeError(
"required_weights must contain HuggingFaceWeight declarations")
self.permissions = tuple(self.permissions or ())
if (self.required_weights
and "models.download" not in self.permissions):
self.permissions += ("models.download",)
if self.input_mode not in {"refs", "values"}:
raise ValueError(
f"V2 node input mode {self.input_mode!r} is not allowed; "
f"expected 'refs' or 'values'")
@runtime_checkable
class ExecutionBackend(Protocol):
async def dispatch(
self,
plan: ExecutionPlan,
local_call: Callable[[], Awaitable[Any]],
runtime: Optional[Runtime] = None,
) -> Any:
"""Run the node. Default just awaits ``local_call`` (in-process). The
overlay routes ``tier == 'sandbox'`` nodes to a guest process instead,
and calls ``local_call`` only for nodes that stay local. ``runtime`` is
the host-side binding (refs/ctx/ops) for this node so an out-of-process
backend can serve brokered guest calls against the same ref table."""
...
@runtime_checkable
class CtxProvider(Protocol):
def build(self, plan: ExecutionPlan) -> "Context": ...
@runtime_checkable
class OpsProvider(Protocol):
"""Engine-side operations on assets — the preferred node interface. The
node passes/receives refs; the buffer math happens here, on the trusted
plane (in-process default) or in the engine (overlay). Dispatch is generic
(``apply(op, image, params)``) so the op vocabulary is data, not API
surface: an overlay adds ops without changing this contract, and a node
can probe ``supports(op)`` to choose a fallback (e.g. the ``raw`` tier)."""
# `subject` is the ref the op acts on — an ImageRef for the pixel ops, but
# a VaeRef/ClipRef/ModelRef for the engine-object ops. The return is
# deliberately Any: most ops yield a ref, `clip.tokenize` yields a plain
# token dict, and annotating that as ImageRef was simply untrue.
async def apply(self, op: str, subject: "Ref", params: dict) -> Any: ...
def supports(self, op: str) -> bool: ...
# --------------------------------------------------------------------------- #
# ctx — the brokered side-effect surface. Interfaces first; in-process defaults
# implement the important ones over real core. Domains not needed by the POC
# are declared and stubbed so the shape is fixed.
# --------------------------------------------------------------------------- #
class AssetsDomain(Protocol):
async def resolve(self, folder: str, name: str) -> AssetRef: ...
async def exists(self, folder: str, name: str) -> bool: ...
async def delete_input(self, name: str) -> bool: ...
async def path(self, ref: AssetRef) -> str: ...
async def list(
self, folder: str, prefix: str = "", recursive: bool = True,
) -> list[str]: ...
async def latest(
self, folder: str, prefix: str = "", suffix: str = "",
) -> Optional[str]: ...
async def size(self, ref: AssetRef) -> int: ...
async def digest(
self, ref: AssetRef, algorithm: str = "sha256",
) -> str: ...
async def read_range(
self, ref: AssetRef, offset: int = 0,
length: int = 8 * 1024 * 1024,
) -> bytes: ...
async def read_bytes(self, ref: AssetRef) -> bytes: ...
async def load_state_dict(
self, ref: AssetRef, return_metadata: bool = False,
) -> Any: ...
async def load_image(self, ref: AssetRef) -> ImageRef: ...
async def load_latent(self, ref: AssetRef) -> LatentRef: ...
class ProgressDomain(Protocol):
async def update(self, value: float, total: float,
preview: Optional[ImageRef] = None) -> None: ...
class ScratchDomain(Protocol):
async def dir(self) -> str: ...
class EventsDomain(Protocol):
async def emit(self, event: str, data: dict) -> None: ...
class InteractionDomain(Protocol):
async def request(
self, kind: str, payload: Any, *, reuse_last: bool = False,
remember: bool = False, timeout: float = 540.0,
) -> Any: ...
class StorageDomain(Protocol):
async def get(self, key: str) -> Optional[str]: ...
async def set(self, key: str, value: str) -> None: ...
class CaptureDomain(Protocol):
async def screen(self, region: Optional[tuple[int, int, int, int]] = None,
monitor: int = 1) -> ImageRef: ...
async def camera(self, index: int = 0, width: Optional[int] = None,
height: Optional[int] = None) -> ImageRef: ...
async def audio(self, duration: float, sample_rate: int = 44100,
channels: int = 1) -> AudioRef: ...
class UiDomain(Protocol):
async def preview_images(self, images: ImageRef,
animated: bool = False) -> dict: ...
async def preview_mask(self, mask: MaskRef,
animated: bool = False) -> dict: ...
async def preview_audio(self, audio: AudioRef) -> dict: ...
async def preview_animation(
self, images: ImageRef, fps: float = 8.0,
) -> dict: ...
async def preview_batch(
self, value: TensorRef, max_thumb_size: int = 512,
crf: int = 25, max_grid_frames: int = 1024,
) -> dict: ...
class OutputDomain(Protocol):
async def save_images(
self, images: ImageRef, filename_prefix: str = "ComfyUI",
subfolder: str = "", compress_level: int = 4,
caption: Optional[str] = None,
caption_extension: str = ".txt",
save_metadata: bool = True,
extra_metadata: Optional[dict[str, Any]] = None,
image_format: str = "png", quality: int = 95,
filenames: Optional[list[str]] = None,
lossless: bool = False, optimize: bool = False,
) -> dict: ...
async def save_images_with_alpha(
self, images: ImageRef, mask: MaskRef,
filename_prefix: str = "ComfyUI",
subfolder: str = "", compress_level: int = 4,
) -> dict: ...
async def save_text(
self, text: str, filename_prefix: str = "text",
subfolder: str = "", extension: str = ".txt",
) -> str: ...
async def write_text(
self, text: str, filename: str, folder: str = "output",
mode: str = "overwrite", insert_newline: bool = False,
) -> str: ...
async def save_workflow_json(
self, filename: str, mode: str = "new_only",
) -> str: ...
async def save_latent(
self, latent: LatentRef,
filename_prefix: str = "latents/LatentSender",
preview_method: str = "Latent2RGB-SDXL",
) -> dict: ...
async def save_state_dict(
self, state_dict: ValueRef, filename_prefix: str,
metadata: Optional[dict[str, str]] = None,
) -> str: ...
async def save_model(
self, model: ModelRef, filename_prefix: str,
model_key_prefix: str = "model.diffusion_model.",
) -> str: ...
async def save_video(
self, images: ImageRef, audio: Optional[AudioRef] = None,
fps: float = 25.0, filename_prefix: str = "video/ComfyUI",
format: str = "auto", codec: str = "auto",
encoder_options: Optional[dict[str, Any]] = None,
loop_count: int = 0, bit_depth: int = 8,
save_output: bool = True, save_metadata: bool = True,
) -> dict: ...
async def save_animation(
self, images: ImageRef, fps: float = 8.0,
filename_prefix: str = "animation/ComfyUI",
format: str = "webp", loop_count: int = 0,
lossless: bool = True, quality: int = 90,
save_output: bool = True,
) -> dict: ...
async def save_image_sequence(
self, images: ImageRef,
filename_prefix: str = "sequence/ComfyUI",
format: str = "png", bit_depth: int = 8,
save_output: bool = True,
) -> dict: ...
class GraphDomain(Protocol):
async def current_node_id(self) -> str: ...
async def input_label(
self, input_name: str, default: str = "",
) -> str: ...
async def expand_nodes(
self, nodes: list[dict[str, Any]], outputs: list[dict[str, Any]],
) -> dict[str, Any]: ...
async def expand_loop(
self, flow: Any, values: list[Any],
) -> dict[str, Any]: ...
async def widget_values(
self, node_id: int | str = 0, node_title: str = "",
node_name: str = "", linked_input: str = "any_input",
) -> dict[str, Any]: ...
async def block(self, reason: Optional[str] = None) -> Any: ...
class ExecutionDomain(Protocol):
async def interrupt(self) -> bool: ...
class CivitaiDomain(Protocol):
"""Bounded read-only projection of the Civitai public model API."""
async def search_models(
self, username: str, query: Optional[str] = None,
limit: int = 20, nsfw: bool = False,
) -> dict[str, Any]: ...
async def model_version(
self, model_version_id: int,
) -> dict[str, Any]: ...
async def model_version_by_hash(
self, hash_value: str, refresh: bool = False,
) -> dict[str, Any]: ...
class OllamaDomain(Protocol):
"""Bounded Ollama vendor API; endpoint is loopback or an admin profile."""
async def list_models(self, endpoint: str) -> list[str]: ...
async def generate(
self, endpoint: str, model: str, system: str, prompt: str,
images: Optional[ImageRef] = None,
context: Optional[list[int]] = None, think: bool = False,
options: Optional[dict[str, Any]] = None, keep_alive: int = 5,
keep_alive_unit: str = "minutes",
format: str | dict[str, Any] = "",
timeout_seconds: float = 600.0,
) -> dict[str, Any]: ...
async def chat(
self, endpoint: str, model: str,
messages: list[dict[str, Any]], images: Optional[ImageRef] = None,
think: bool = False, options: Optional[dict[str, Any]] = None,
keep_alive: int = 5, keep_alive_unit: str = "minutes",
format: str | dict[str, Any] = "", timeout_seconds: float = 600.0,
tools: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]: ...
class LlmDomain(Protocol):
"""Provider-neutral bounded chat and function-tool contract."""
async def chat(
self, provider: str, profile: str, model: str,
messages: list[dict[str, Any]], *,
tools: Optional[list[dict[str, Any]]] = None,
temperature: float = 0.8, max_tokens: int = 512,
thinking: bool = False,
response_format: str | dict[str, Any] = "",
timeout_seconds: float = 600.0,
vendor_options: Optional[dict[str, Any]] = None,
) -> dict[str, Any]: ...
class WebSearchDomain(Protocol):
"""Fixed-profile web search with bounded normalized results."""
async def search(
self, query: str, *, provider_profile: str = "duckduckgo",
limit: int = 5,
vendor_options: Optional[dict[str, Any]] = None,
) -> list[dict[str, str]]: ...
class LlamaCppDomain(Protocol):
"""Bounded llama.cpp vendor adapter over managed GGUF weights."""
async def load_chat_model(
self, model_weight: str, mmproj_weight: Optional[str] = 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,
) -> LlamaCppModelRef: ...
async def generate(
self, model: LlamaCppModelRef, system: str, prompt: str,
image: Optional[ImageRef] = None,
video: Optional[ImageRef] = None,
max_tokens: int = 512, temperature: float = 0.7,
top_p: float = 0.9, repetition_penalty: float = 1.0,
seed: int = 1,
) -> str: ...
class WanVideoDomain(Protocol):
"""Bounded metadata for WanVideo vendor-owned opaque model handles."""
async def transformer_dim(self, model: Ref) -> int: ...
class AnimaDomain(Protocol):
"""Vendor-specific Anima model adapters."""
async def apply_lllite(
self, model: ModelRef, weights: AssetRef, image: ImageRef, *,
strength: float = 1.0, start_percent: float = 0.0,
end_percent: float = 1.0, preserve_wrapper: bool = True,
) -> ModelRef: ...
class IntegrationsDomain(Protocol):
"""Vendor pass-throughs with vendor-shaped, less-stable contracts."""
anima: AnimaDomain
civitai: CivitaiDomain
llm: LlmDomain
llama_cpp: LlamaCppDomain
ollama: OllamaDomain
wanvideo: WanVideoDomain
web: WebSearchDomain
class ModelsDomain(Protocol):
async def download_huggingface_weights(
self, repo_id: str, filename: str, folder: str,
revision: str = "main", sha256: Optional[str] = None,
) -> str: ...
async def list_diffusion_models(
self, include_connectors: bool = False,
) -> list[str]: ...
async def load_checkpoint(
self, name: str, weight_dtype: str = "default",
compute_dtype: str = "default", cublas_linear: bool = False,
) -> tuple[ModelRef, Optional[ClipRef], Optional[VaeRef]]: ...
async def load_diffusion_model(
self, name: str, extra_name: Optional[str] = None,
weight_dtype: str = "default", compute_dtype: str = "default",
cublas_linear: bool = False,
) -> ModelRef: ...
async def load_gguf_model(
self, name: str, extra_name: Optional[str] = None,
dequant_dtype: str = "default", patch_dtype: str = "default",
patch_on_device: bool = False,
) -> ModelRef: ...
async def load_gguf_text_encoders(
self, names: Sequence[str], clip_type: str,
) -> ClipRef: ...
async def list_controlnet(self) -> list[str]: ...
async def load_controlnet(
self, name: str, model: Optional[ModelRef] = None,
) -> ControlNetRef: ...
async def load_advanced_controlnet(
self, name: str, model: Optional[ModelRef] = None,
timestep_keyframe: Optional[TimestepKeyframeRef] = None,
) -> ControlNetRef: ...
async def load_controlnet_plusplus(
self, name: str, control_type: str = "none",
) -> ControlNetRef: ...
async def list_vae(self) -> list[str]: ...
async def load_vae(
self, name: str, device: str = "default",
weight_dtype: str = "default",
) -> VaeRef: ...
async def load_upscale_model(self, name: str) -> UpscaleModelRef: ...
async def load_clip_vision(self, model: str) -> ClipVisionRef: ...
async def load_text_encoder(
self, model: str, model_type: str,
device: str = "default",
) -> ClipRef: ...
async def load_language_model(
self, weights: list[str], family: str,
device: str = "default", cache: bool = True,
) -> ClipRef: ...
async def load_ipadapter(
self, model: str, clip_vision: ClipVisionRef,
) -> IpAdapterRef: ...
async def load_brushnet(
self, model: str, dtype: str = "float16",
) -> BrushNetRef: ...
async def load_powerpaint(
self, model: str, base_clip: str, powerpaint_clip: str,
dtype: str = "float16",
) -> PowerPaintRef: ...
async def load_transparent_vae_decoder(
self, model: str, family: str,
) -> TransparentVaeDecoderRef: ...
async def load_clipseg(self, model: str) -> ClipSegRef: ...
async def load_image_classifier(
self, model: str, architecture: str, labels: list[str],
) -> ImageClassifierRef: ...
async def load_onnx_image_classifier(
self, model: str, input_layout: str = "NHWC",
channel_order: str = "BGR", resize_mode: str = "fit_pad",
input_scale: float = 255.0,
pad_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
mean: tuple[float, float, float] = (0.0, 0.0, 0.0),
std: tuple[float, float, float] = (1.0, 1.0, 1.0),
activation: str = "identity", resize_filter: str = "lanczos",
) -> ImageClassifierRef: ...
async def load_segformer(
self, model: str, variant: str, num_labels: int,
) -> SemanticSegmentationRef: ...
async def load_vitmatte(
self, model: str, variant: str,
) -> MattingModelRef: ...
async def load_vqa(
self, model: str, architecture: str,
precision: str = "fp16", device: str = "cuda",
) -> VqaModelRef: ...
async def load_inpaint_model(
self, model: str, architecture: str = "big-lama",
) -> InpaintModelRef: ...
async def load_background_removal_model(
self, model: str,
) -> BackgroundRemovalModelRef: ...
async def load_onnx_detector(self, model: str) -> OnnxDetectorRef: ...
async def load_object_detector(self, model: str) -> ObjectDetectorRef: ...
async def load_sam(
self, model: str, architecture: str = "vit_b",
device_mode: str = "AUTO",
) -> SamModelRef: ...
async def generate_text(
self, generator: str, input_text: str, max_new_tokens: int = 128,
weight: Optional[str] = None,
) -> str: ...
async def memory_cleanup(
self, empty_cache: bool = True, collect_cycles: bool = True,
unload_all_models: bool = False,
) -> tuple[int, int]: ...
class ProfilingDomain(Protocol):
async def cuda_memory_start(
self, *, enabled: str = "all", context: str = "all",
stacks: str = "all", max_entries: int = 100000,
) -> None: ...
async def cuda_memory_end(
self, filename_prefix: str = "comfy_cuda_memory_history",
) -> str: ...
async def cuda_memory_visualize(self, snapshot: str) -> str: ...
class PreviewOverrideDomain(Protocol):
async def attach(
self, model: ModelRef, *, max_resolution: int = 1024,
jpeg_quality: int = 80, suppress_default_preview: bool = True,
preview_frames: int = 1, preview_fps: int = 12,
vae: Optional[VaeRef] = None, tiny_vae: str = "none",
) -> ModelRef: ...
async def attach_ltx2(
self, model: ModelRef, *, preview_rate: float = 8.0,
latent_upscale_model: Optional[Ref] = None,
vae: Optional[VaeRef] = None,
) -> ModelRef: ...
async def frames(
self, model: ModelRef, after_sample: Ref,
) -> ImageRef: ...
class SystemDomain(Protocol):
async def stats(self) -> dict[str, Any]: ...
async def monitor(self) -> dict[str, Any]: ...
class ClosuresDomain(Protocol):
async def retain(
self, kind: str, fn: Callable, *, captures: Optional[dict] = None,
) -> ClosureRef: ...
async def attach_model(
self, closure: ClosureRef, model: ModelRef,
) -> ModelRef: ...
async def attach_sampler(
self, closure: ClosureRef, sampler: SamplerRef, *,
start_percent: Optional[float] = None,
end_percent: Optional[float] = None,
) -> SamplerRef: ...
async def create_latent_operation(
self, closure: ClosureRef,
) -> LatentOperationRef: ...
async def create_sampler(
self, closure: ClosureRef,
) -> SamplerRef: ...
class Context(Protocol):
assets: AssetsDomain
progress: ProgressDomain
scratch: ScratchDomain
events: EventsDomain
storage: StorageDomain
capture: CaptureDomain
ui: UiDomain
output: OutputDomain
graph: GraphDomain
execution: ExecutionDomain
integrations: IntegrationsDomain
models: ModelsDomain
profiling: ProfilingDomain
preview_override: PreviewOverrideDomain
system: SystemDomain
closures: ClosuresDomain
interact: InteractionDomain
sample: Any
unsample: Any
# Declared for the contract; overlay/full-SDK implement. Stubbed in OSS
# default until wired: models, sample, serve, secrets, net.
# --------------------------------------------------------------------------- #
# Default in-process implementations (OSS). All heavy imports are lazy so this
# module stays import-safe.
# --------------------------------------------------------------------------- #
class InProcessRefResolver:
"""Identity table. A ref wraps the real object; resolve returns it as-is.
**The table is authoritative for a ref's kind.** Ref tokens cross the wire
as ``{"kind": ..., "id": ..., "cls": ...}``, so the label on an arriving
token is whatever the sender put there. `resolve` therefore checks it
against the kind recorded at creation and refuses a mismatch.
Unguessable ids (uuid4) stop a holder reaching a ref it was never handed.
This check is the other half: possessing a handle is not the same as
labelling it, and without the check an IMAGE id presented as
``{"cls": "VaeRef", "kind": "VAE"}`` would reach an op that dispatches on
kind, handing a tensor to code expecting a VAE.
"""
def __init__(self) -> None:
self._table: dict[str, tuple[str, Any]] = {}
self._closed = False
async def create(self, kind: str, obj: Any) -> Ref:
if self._closed:
raise RuntimeError("this node execution has ended; its ref table is closed")
rid = uuid.uuid4().hex
self._table[rid] = (kind, obj)
return Ref(kind=kind, id=rid)
async def resolve(self, ref: Ref) -> Any:
entry = self._table.get(ref.id)
if entry is None:
raise KeyError(f"unknown ref {ref!r}")
kind, obj = entry
if ref.kind != kind:
raise TypeError(
f"ref {ref.id[:8]} was created as {kind} but presented as "
f"{ref.kind}; the holder of a handle does not get to relabel it")
return obj
async def release(self, ref: Ref) -> None:
self._table.pop(ref.id, None)
def clear(self) -> int:
"""Drop every entry. Returns how many.
Called at the end of a node execution so the table's strong references
go at a known point, rather than whenever the interpreter next collects
the frame that owned it. A ref table can hold multi-gigabyte tensors;
"freed eventually" is not a lifetime for those, and a reference cycle
anywhere in the graph defers it indefinitely.
Refcount timing also does not cross a process boundary, so it can never
be the whole answer here — the out-of-band channel releases explicitly
(`transport/shm.py`), and this is the in-process half of the same rule:
nothing waits for the collector.
"""
n = len(self._table)
self._table.clear()
self._closed = True
return n
class _InProcessAssets:
_LIST_MAX = 4096
_SCAN_MAX = 100000
_DIGEST_CACHE_MAX = 256
_DIGEST_CACHE: "OrderedDict[tuple[Any, ...], str]" = OrderedDict()
_DIGEST_LOCK = threading.Lock()
_IMAGE_FILE_MAX = 64 * 1024 * 1024
_IMAGE_PIXELS_MAX = 67_108_864
@staticmethod
def _confined_path(base: str, name: str, folder: str) -> str:
base = os.path.realpath(os.path.abspath(base))
logical = str(name or "").replace("\\", "/")
if "\x00" in logical or logical.startswith("/"):
raise ValueError(f"asset name escapes the {folder} directory")
parts = [part for part in logical.split("/") if part not in ("", ".")]
if any(part == ".." for part in parts):
raise ValueError(f"asset name escapes the {folder} directory")
full = os.path.realpath(os.path.join(base, *parts))
try:
confined = os.path.commonpath((base, full)) == base
except ValueError:
confined = False
if not confined:
raise ValueError(f"asset name escapes the {folder} directory")
return full
@staticmethod
def _confined_resolved_path(
path: str, roots: list[str], folder: str,
) -> str:
full = os.path.realpath(path)
for candidate in roots:
root = os.path.realpath(os.path.abspath(candidate))
try:
if os.path.commonpath((root, full)) == root:
return full
except ValueError:
continue
raise ValueError(f"asset name escapes the {folder} directory")
async def resolve(self, folder: str, name: str) -> AssetRef:
import folder_paths # lazy
standard_folders = {
"input": folder_paths.get_input_directory,
"output": folder_paths.get_output_directory,
"temp": folder_paths.get_temp_directory,
}
if folder in standard_folders:
base = standard_folders[folder]()
full = self._confined_path(base, name, folder)
if not os.path.isfile(full):
raise FileNotFoundError(f"no {folder} asset named {name!r}")
else:
roots = folder_paths.get_folder_paths(folder)
full = None
for root in roots:
candidate = self._confined_path(root, name, folder)
if os.path.isfile(candidate):
full = candidate
break
if full is None:
raise FileNotFoundError(
f"no {folder} asset named {name!r}")
return AssetRef._wrap(await current_runtime().refs.create("ASSET", full)) # type: ignore[return-value]
async def exists(self, folder: str, name: str) -> bool:
import folder_paths
standard_folders = {
"input": folder_paths.get_input_directory,
"output": folder_paths.get_output_directory,
"temp": folder_paths.get_temp_directory,
}
if folder in standard_folders:
return os.path.isfile(self._confined_path(
standard_folders[folder](), name, folder))
return any(
os.path.isfile(self._confined_path(root, name, folder))
for root in folder_paths.get_folder_paths(folder)
)
async def delete_input(self, name: str) -> bool:
import folder_paths
path = self._confined_path(
folder_paths.get_input_directory(), name, "input")
if not os.path.isfile(path):
return False
os.remove(path)
return True
async def path(self, ref: AssetRef) -> str:
return await current_runtime().refs.resolve(ref)
async def list(
self, folder: str, prefix: str = "", recursive: bool = True,
) -> list[str]:
import folder_paths
managed_folders = {
"input": folder_paths.get_input_directory,
"output": folder_paths.get_output_directory,
"temp": folder_paths.get_temp_directory,
}
if folder not in managed_folders:
names = sorted(
str(name).replace("\\", "/")
for name in folder_paths.get_filename_list(folder))
logical_prefix = str(prefix or "").replace("\\", "/").strip("/")
if logical_prefix:
if "\x00" in logical_prefix or any(
part == ".." for part in logical_prefix.split("/")):
raise ValueError("asset prefix escapes the catalogue")
marker = logical_prefix + "/"
names = [
name for name in names
if name == logical_prefix or name.startswith(marker)]
if not recursive:
names = [
name for name in names
if "/" not in name[len(marker):]]
elif not recursive:
names = [name for name in names if "/" not in name]
if len(names) > self._LIST_MAX:
raise ValueError(
f"asset catalogue exceeds {self._LIST_MAX} names")
return names
base = os.path.realpath(os.path.abspath(managed_folders[folder]()))
directory = self._confined_path(base, prefix, folder)
if not os.path.exists(directory):
return []
if not os.path.isdir(directory):
raise NotADirectoryError(
f"{folder} asset prefix {prefix!r} is not a directory")
names: list[str] = []
for root, directories, files in os.walk(
directory, followlinks=False):
directories.sort()
files.sort()
for filename in files:
full = os.path.join(root, filename)
real = os.path.realpath(full)
try:
confined = os.path.commonpath((base, real)) == base
except ValueError:
confined = False
if not confined or not os.path.isfile(real):
continue
names.append(os.path.relpath(full, base).replace(os.sep, "/"))
if len(names) > self._LIST_MAX:
raise ValueError(
f"{folder} asset catalogue exceeds "
f"{self._LIST_MAX} names")
if not recursive:
break
return sorted(names)
async def latest(
self, folder: str, prefix: str = "", suffix: str = "",
) -> Optional[str]:
"""Newest logical file in one managed user-media directory.
This exposes neither paths nor a general stat primitive. It exists
for nodes whose behavior is specifically "reuse the latest output".
"""
import folder_paths
roots = {
"input": folder_paths.get_input_directory,
"output": folder_paths.get_output_directory,
"temp": folder_paths.get_temp_directory,
}
if folder not in roots:
raise ValueError("latest is limited to input, output, and temp assets")
base = os.path.realpath(os.path.abspath(roots[folder]()))
logical_prefix = str(prefix or "").replace("\\", "/").lstrip("/")
if logical_prefix.startswith(folder + "/"):
logical_prefix = logical_prefix[len(folder) + 1:]
if ("\x00" in logical_prefix
or any(part == ".." for part in logical_prefix.split("/"))):
raise ValueError("asset prefix escapes the managed directory")
suffix_value = str(suffix or "")
if (len(suffix_value) > 256 or "\x00" in suffix_value
or "/" in suffix_value or "\\" in suffix_value):
raise ValueError("asset suffix must be a filename suffix")
newest: Optional[tuple[int, str]] = None
examined = 0
for root, directories, files in os.walk(base, followlinks=False):
directories.sort()
files.sort()
for filename in files:
examined += 1
if examined > self._SCAN_MAX:
raise ValueError(
f"managed directory exceeds {self._SCAN_MAX} files")
full = os.path.realpath(os.path.join(root, filename))
try:
confined = os.path.commonpath((base, full)) == base
except ValueError:
confined = False
if not confined or not os.path.isfile(full):
continue
logical = os.path.relpath(full, base).replace(os.sep, "/")
if (not logical.startswith(logical_prefix)
or not logical.endswith(suffix_value)):
continue
candidate = (os.stat(full).st_mtime_ns, logical)
if newest is None or candidate > newest:
newest = candidate
return newest[1] if newest is not None else None
async def read_bytes(self, ref: AssetRef) -> bytes:
path = await self.path(ref)
with open(path, "rb") as file:
return file.read()
async def size(self, ref: AssetRef) -> int:
path = await self.path(ref)
return int(os.path.getsize(path))
async def digest(
self, ref: AssetRef, algorithm: str = "sha256",
) -> str:
"""Hash a managed asset without returning its path or loading it whole.
The cache key is the opened file's identity, so a replacement or edit
invalidates the entry. Only SHA-256 is exposed initially: the point is
stable model identity, not a general cryptography surface.
"""
if algorithm != "sha256":
raise ValueError("asset digest algorithm must be sha256")
path = await self.path(ref)
if not isinstance(path, (str, os.PathLike)):
raise TypeError("ASSET ref does not contain a managed file")
def compute() -> str:
import hashlib
with open(path, "rb") as stream:
before = os.fstat(stream.fileno())
key = (
os.path.realpath(os.fspath(path)), before.st_dev,
before.st_ino, before.st_size, before.st_mtime_ns,
before.st_ctime_ns, algorithm,
)
with self._DIGEST_LOCK:
cached = self._DIGEST_CACHE.get(key)
if cached is not None:
self._DIGEST_CACHE.move_to_end(key)
return cached
hasher = hashlib.sha256()
for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""):
hasher.update(chunk)
after = os.fstat(stream.fileno())
if (
before.st_dev, before.st_ino, before.st_size,
before.st_mtime_ns, before.st_ctime_ns,
) != (
after.st_dev, after.st_ino, after.st_size,
after.st_mtime_ns, after.st_ctime_ns,
):
raise RuntimeError("asset changed while its digest was computed")
value = hasher.hexdigest()
with self._DIGEST_LOCK:
self._DIGEST_CACHE[key] = value
self._DIGEST_CACHE.move_to_end(key)
while len(self._DIGEST_CACHE) > self._DIGEST_CACHE_MAX:
self._DIGEST_CACHE.popitem(last=False)
return value
return await asyncio.to_thread(compute)
async def read_range(
self, ref: AssetRef, offset: int = 0,
length: int = 8 * 1024 * 1024,
) -> bytes:
start = int(offset)
count = int(length)
if start != offset or start < 0:
raise ValueError("asset range offset must be a non-negative integer")
if count != length or not 1 <= count <= 16 * 1024 * 1024:
raise ValueError("asset range length must be in [1, 16 MiB]")
path = await self.path(ref)
with open(path, "rb") as file:
file.seek(start)
return file.read(count)
async def load_state_dict(
self, ref: AssetRef, return_metadata: bool = False,
) -> Any:
import comfy.utils
path = await self.path(ref)
return comfy.utils.load_torch_file(
path, safe_load=True, return_metadata=bool(return_metadata))
async def load_image(self, ref: AssetRef) -> ImageRef:
import numpy as np
import torch
from PIL import Image, ImageOps
path = await self.path(ref)
if os.path.getsize(path) > self._IMAGE_FILE_MAX:
raise ValueError("image asset exceeds the encoded size limit")
def decode():
with Image.open(path) as source:
source.seek(0)
image = ImageOps.exif_transpose(source)
width, height = image.size
if (
width < 1 or height < 1
or width * height > self._IMAGE_PIXELS_MAX
):
raise ValueError("image asset dimensions exceed the limit")
rgb = image.convert("RGB")
rgb.load()
return np.asarray(rgb, dtype=np.float32).copy()
array = await asyncio.to_thread(decode)
pixels = torch.from_numpy(array).div_(255.0).unsqueeze(0)
return ImageRef._wrap(await current_runtime().refs.create(
"IMAGE", pixels)) # type: ignore[return-value]
async def load_latent(self, ref: AssetRef) -> LatentRef:
"""Load ComfyUI's safetensors-backed ``.latent`` format.
This deliberately does not accept pickle or image containers. A
legacy ``.latent.png`` may still be decoded by a sandboxed node, but
no image/EXIF/ZIP parser is moved into the trusted plane for it.
"""
import torch
from safetensors.torch import load_file
path = await self.path(ref)
if not str(path).lower().endswith(".latent"):
raise ValueError("latent assets must use the safe .latent format")
value = load_file(path, device="cpu")
if not isinstance(value, dict) or "latent_tensor" not in value:
raise ValueError("latent asset has no latent_tensor")
tensor = value["latent_tensor"]
if not isinstance(tensor, torch.Tensor) or tensor.ndim not in (4, 5):
raise ValueError("latent_tensor must be a 4D or 5D tensor")
multiplier = 1.0 if "latent_format_version_0" in value else 1.0 / 0.18215
result = {"samples": tensor.float() * multiplier}
return LatentRef._wrap(await current_runtime().refs.create(
"LATENT", result)) # type: ignore[return-value]
def _load_sdk_diffusion_model(
model_path: str, model_options: Optional[dict] = None,
extra_path: Optional[str] = None,
):
import comfy.sd
import comfy.utils
options = {} if model_options is None else dict(model_options)
state_dict, metadata = comfy.utils.load_torch_file(
model_path, return_metadata=True)
if extra_path is not None:
state_dict.update(comfy.utils.load_torch_file(extra_path))
prefix = comfy.sd.model_detection.unet_prefix_from_state_dict(
state_dict)
state_dict = comfy.utils.state_dict_prefix_replace(
state_dict, {prefix: ""}, filter_keys=False)
model = comfy.sd.load_diffusion_model_state_dict(
state_dict, model_options=options, metadata=metadata)
if model is None:
raise RuntimeError("could not detect the selected diffusion model type")
model.cached_patcher_init = (
_load_sdk_diffusion_model,
(model_path, options, extra_path),
)
return model
@dataclass
class _TextGeneratorEntry:
tokenizer: Any
model: Any
device: Any
lock: threading.Lock = field(default_factory=threading.Lock)
def _load_fixed_text_generator(
generator: str, weight_path: str,
) -> _TextGeneratorEntry:
if generator != "superprompt-v1":
raise ValueError(
f"text generator {generator!r} is not in the trusted catalogue")
if (not isinstance(weight_path, str)
or not weight_path.lower().endswith(".safetensors")
or not os.path.isfile(weight_path)):
raise ValueError(
"text generator 'superprompt-v1' requires a SafeTensors weight")
try:
import comfy.model_management
from safetensors.torch import load_file
from transformers import (
T5Config,
T5ForConditionalGeneration,
T5TokenizerFast,
)
except ImportError as exc:
raise RuntimeError(
"text generator 'superprompt-v1' requires the transformers "
"and safetensors packages") from exc
# SuperPrompt is a fine-tuned flan-t5-small model. Construct its fixed
# architecture here so repository config can never become executable or
# policy-bearing input to the trusted process.
config = T5Config(
vocab_size=32128,
d_model=512,
d_kv=64,
d_ff=1024,
num_layers=8,
num_decoder_layers=8,
num_heads=6,
relative_attention_num_buckets=32,
relative_attention_max_distance=128,
dropout_rate=0.1,
layer_norm_epsilon=1e-6,
initializer_factor=1.0,
feed_forward_proj="gated-gelu",
is_encoder_decoder=True,
use_cache=True,
pad_token_id=0,
eos_token_id=1,
decoder_start_token_id=0,
tie_word_embeddings=False,
)
# Some Transformers releases normalize this legacy field back to True in
# T5Config.__init__. SuperPrompt stores a distinct learned lm_head, so set
# it explicitly before model construction to prevent destructive tying.
config.tie_word_embeddings = False
model = T5ForConditionalGeneration(config)
state = load_file(weight_path, device="cpu")
shared = state.get("shared.weight")
if shared is None:
raise ValueError("SuperPrompt weights have no shared embedding")
# SafeTensors deliberately stores the shared T5 embedding only once.
# Restore the two state-dict aliases before performing a strict load.
state["encoder.embed_tokens.weight"] = shared
state["decoder.embed_tokens.weight"] = shared
model.load_state_dict(state, strict=True)
tokenizer_root = os.path.realpath(os.path.join(
os.path.dirname(__file__), "..", "..", "comfy",
"text_encoders", "t5_tokenizer"))
tokenizer_file = os.path.join(tokenizer_root, "tokenizer.json")
if not os.path.isfile(tokenizer_file):
raise RuntimeError("ComfyUI's bundled T5 tokenizer is unavailable")
tokenizer = T5TokenizerFast(
tokenizer_file=tokenizer_file,
model_max_length=512,
pad_token="<pad>",
eos_token="</s>",
unk_token="<unk>",
)
device = comfy.model_management.get_torch_device()
model.to(device)
model.eval()
return _TextGeneratorEntry(tokenizer, model, device)
class _TextGeneratorCache:
def __init__(self, max_entries: int = 1) -> None:
if max_entries < 1:
raise ValueError("text generator cache must hold at least one entry")
self.max_entries = max_entries
self._entries: dict[tuple[Any, ...], _TextGeneratorEntry] = {}
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
self.evictions = 0
@staticmethod
def _key(generator: str, weight_path: str) -> tuple[Any, ...]:
status = os.stat(weight_path)
return (
generator,
os.path.realpath(weight_path),
status.st_dev,
status.st_ino,
status.st_size,
status.st_mtime_ns,
status.st_ctime_ns,
)
def _entry(
self, generator: str, weight_path: str,
) -> _TextGeneratorEntry:
key = self._key(generator, weight_path)
with self._lock:
entry = self._entries.get(key)
if entry is not None:
self.hits += 1
return entry
entry = _load_fixed_text_generator(generator, weight_path)
self.loads += 1
while len(self._entries) >= self.max_entries:
_, evicted = self._entries.popitem()
self._release(evicted)
self.evictions += 1
self._entries[key] = entry
return entry
@staticmethod
def _release(entry: _TextGeneratorEntry) -> None:
with entry.lock:
entry.model.to("cpu")
def generate(
self, generator: str, weight_path: str, input_text: str,
max_new_tokens: int,
) -> str:
import torch
entry = self._entry(generator, weight_path)
with entry.lock, torch.inference_mode():
input_ids = entry.tokenizer(
input_text, return_tensors="pt").input_ids.to(entry.device)
outputs = entry.model.generate(
input_ids, max_new_tokens=max_new_tokens)
result = entry.tokenizer.decode(outputs[0])
if not isinstance(result, str):
raise TypeError("text generator decoder must return a string")
return result
def clear(self) -> int:
with self._lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
self._release(entry)
return len(entries)
def stats(self) -> dict[str, int]:
with self._lock:
return {
"entries": len(self._entries),
"max_entries": self.max_entries,
"loads": self.loads,
"hits": self.hits,
"evictions": self.evictions,
}
_TEXT_GENERATOR_CACHE = _TextGeneratorCache()
@dataclass
class _InpaintModelEntry:
model: Any
architecture: str
lock: threading.Lock = field(default_factory=threading.Lock)
def bundle(self) -> dict[str, Any]:
return {
"secure_kind": f"image_inpaint.{self.architecture}",
"model": self.model,
"architecture": self.architecture,
"lock": self.lock,
}
def _load_inpaint_model_weight(
path: str, architecture: str,
) -> _InpaintModelEntry:
import torch
import comfy.ops
from comfy.ldm.lama import BigLamaGenerator
from safetensors.torch import load_file
if architecture != "big-lama":
raise ValueError(
f"inpaint architecture {architecture!r} is not supported")
state = load_file(path, device="cpu")
if not state:
raise ValueError("Big-LaMa SafeTensors file contains no weights")
if any(not key.startswith("generator.") for key in state):
raise ValueError("Big-LaMa weights contain an unexpected key prefix")
floating_dtypes = {
value.dtype for value in state.values()
if isinstance(value, torch.Tensor) and value.is_floating_point()
}
if floating_dtypes != {torch.float32}:
raise ValueError("Big-LaMa weights must use float32")
state = {
key.removeprefix("generator."): value
for key, value in state.items()
}
with torch.device("meta"):
model = BigLamaGenerator(comfy.ops.disable_weight_init)
model.load_state_dict(state, strict=True, assign=True)
model.eval()
return _InpaintModelEntry(model=model, architecture=architecture)
class _InpaintModelCache:
def __init__(self, max_entries: int = 1) -> None:
self.max_entries = max_entries
self._entries: dict[tuple[Any, ...], _InpaintModelEntry] = {}
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(path: str, architecture: str) -> tuple[Any, ...]:
status = os.stat(path)
return (
os.path.realpath(path), architecture,
status.st_dev, status.st_ino, status.st_size,
status.st_mtime_ns, status.st_ctime_ns,
)
def get(self, path: str, architecture: str) -> _InpaintModelEntry:
key = self._key(path, architecture)
with self._lock:
entry = self._entries.get(key)
if entry is not None:
self.hits += 1
return entry
entry = _load_inpaint_model_weight(path, architecture)
self.loads += 1
while len(self._entries) >= self.max_entries:
_key, evicted = self._entries.popitem()
with evicted.lock:
evicted.model.to("cpu")
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
with entry.lock:
entry.model.to("cpu")
return len(entries)
_INPAINT_MODEL_CACHE = _InpaintModelCache()
@dataclass
class _ClipSegEntry:
model: Any
processor: Any
lock: threading.Lock = field(default_factory=threading.Lock)
def bundle(self) -> dict[str, Any]:
return {
"model": self.model,
"processor": self.processor,
"lock": self.lock,
}
def _load_clipseg_weight(path: str) -> _ClipSegEntry:
"""Build the one supported CLIPSeg architecture from one SafeTensors file.
The architecture and image-processing configuration are trusted code, and
the CLIP tokenizer vocabulary is bundled with ComfyUI. No model config,
tokenizer, processor, or executable file is fetched from the model repo.
"""
import torch
from safetensors.torch import load_file
from transformers import (
CLIPSegConfig,
CLIPSegForImageSegmentation,
CLIPSegProcessor,
CLIPSegTextConfig,
CLIPSegVisionConfig,
CLIPTokenizer,
ViTImageProcessor,
)
state = load_file(path, device="cpu")
if not state:
raise ValueError("CLIPSeg SafeTensors file contains no weights")
floating_dtypes = {
value.dtype for value in state.values()
if isinstance(value, torch.Tensor) and value.is_floating_point()
}
if len(floating_dtypes) != 1:
raise ValueError("CLIPSeg weights must use one floating-point dtype")
dtype = next(iter(floating_dtypes))
if dtype not in (torch.float16, torch.bfloat16, torch.float32):
raise ValueError("CLIPSeg weights use an unsupported dtype")
text_config = CLIPSegTextConfig(
hidden_act="quick_gelu",
hidden_size=512,
intermediate_size=2048,
num_attention_heads=8,
num_hidden_layers=12,
max_position_embeddings=77,
vocab_size=49408,
bos_token_id=0,
eos_token_id=2,
pad_token_id=1,
)
vision_config = CLIPSegVisionConfig(
hidden_act="quick_gelu",
hidden_size=768,
intermediate_size=3072,
num_attention_heads=12,
num_hidden_layers=12,
image_size=224,
patch_size=16,
num_channels=3,
)
config = CLIPSegConfig(
text_config=text_config,
vision_config=vision_config,
projection_dim=512,
reduce_dim=64,
extract_layers=(3, 6, 9),
conditional_layer=0,
decoder_attention_dropout=0.0,
decoder_hidden_act="quick_gelu",
decoder_intermediate_size=2048,
decoder_num_attention_heads=4,
use_complex_transposed_convolution=True,
)
model = CLIPSegForImageSegmentation(config).to(dtype=dtype)
# Transformers versions disagree on whether these deterministic buffers
# are serialized. They are arange-derived, not learned model weights.
for key in (
"clip.text_model.embeddings.position_ids",
"clip.vision_model.embeddings.position_ids",
):
state.pop(key, None)
model.load_state_dict(state, strict=True)
model.eval()
tokenizer_root = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "..", "comfy", "sd1_tokenizer"))
vocab_file = os.path.join(tokenizer_root, "vocab.json")
merges_file = os.path.join(tokenizer_root, "merges.txt")
if not os.path.isfile(vocab_file) or not os.path.isfile(merges_file):
raise RuntimeError("ComfyUI's bundled CLIP tokenizer is unavailable")
tokenizer = CLIPTokenizer(
vocab_file=vocab_file,
merges_file=merges_file,
bos_token="<|startoftext|>",
eos_token="<|endoftext|>",
unk_token="<|endoftext|>",
pad_token="<|endoftext|>",
model_max_length=77,
)
image_processor = ViTImageProcessor(
do_resize=True,
size={"height": 352, "width": 352},
resample=2,
do_rescale=True,
rescale_factor=1.0 / 255.0,
do_normalize=True,
image_mean=(0.485, 0.456, 0.406),
image_std=(0.229, 0.224, 0.225),
)
processor = CLIPSegProcessor(
image_processor=image_processor,
tokenizer=tokenizer,
)
return _ClipSegEntry(model=model, processor=processor)
class _ClipSegCache:
"""Cache loaded CLIPSeg weights by immutable file identity."""
def __init__(self, max_entries: int = 2) -> None:
self.max_entries = max_entries
self._entries: dict[tuple[Any, ...], _ClipSegEntry] = {}
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(path: str) -> tuple[Any, ...]:
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, path: str) -> _ClipSegEntry:
key = self._key(path)
with self._lock:
entry = self._entries.get(key)
if entry is not None:
self.hits += 1
return entry
entry = _load_clipseg_weight(path)
self.loads += 1
while len(self._entries) >= self.max_entries:
_key, evicted = self._entries.popitem()
with evicted.lock:
evicted.model.to("cpu")
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
with entry.lock:
entry.model.to("cpu")
return len(entries)
_CLIPSEG_CACHE = _ClipSegCache()
@dataclass
class _ImageClassifierEntry:
model: Any
processor: Any
architecture: str
num_labels: int
lock: threading.Lock = field(default_factory=threading.Lock)
def _load_image_classifier_weight(
path: str, architecture: str,
) -> _ImageClassifierEntry:
"""Build one closed image-classifier architecture from SafeTensors."""
import torch
from safetensors.torch import load_file
from transformers import (
BeitConfig,
BeitForImageClassification,
BeitImageProcessor,
ConvNextImageProcessor,
ResNetConfig,
ResNetForImageClassification,
ViTConfig,
ViTForImageClassification,
ViTImageProcessor,
)
state = load_file(path, device="cpu")
if not state:
raise ValueError("classifier SafeTensors file contains no weights")
heads = {
"vit-base-patch16-224": "classifier.weight",
"beit-base-patch16-224": "classifier.weight",
"resnet-50-224": "classifier.1.weight",
}
if architecture not in heads:
raise ValueError("image classifier architecture is not supported")
head = state.get(heads[architecture])
if not isinstance(head, torch.Tensor) or head.ndim != 2:
raise ValueError("classifier weights have no compatible output head")
num_labels = int(head.shape[0])
if not 1 <= num_labels <= 10_000:
raise ValueError("classifier output count is outside the safe range")
floating_dtypes = {
value.dtype for value in state.values()
if isinstance(value, torch.Tensor) and value.is_floating_point()
}
if len(floating_dtypes) != 1:
raise ValueError("classifier weights must use one floating-point dtype")
dtype = next(iter(floating_dtypes))
if dtype not in (torch.float16, torch.bfloat16, torch.float32):
raise ValueError("classifier weights use an unsupported dtype")
if architecture == "vit-base-patch16-224":
config = ViTConfig(
num_labels=num_labels,
attention_probs_dropout_prob=0.0,
encoder_stride=16,
hidden_act="gelu",
hidden_dropout_prob=0.0,
hidden_size=768,
image_size=224,
initializer_range=0.02,
intermediate_size=3072,
layer_norm_eps=1e-12,
num_attention_heads=12,
num_channels=3,
num_hidden_layers=12,
patch_size=16,
qkv_bias=True,
)
model = ViTForImageClassification(config)
processor = ViTImageProcessor(
do_resize=True,
size={"height": 224, "width": 224},
resample=2,
do_rescale=True,
rescale_factor=1.0 / 255.0,
do_normalize=True,
image_mean=(0.5, 0.5, 0.5),
image_std=(0.5, 0.5, 0.5),
)
elif architecture == "beit-base-patch16-224":
config = BeitConfig(
num_labels=num_labels,
attention_probs_dropout_prob=0.0,
drop_path_rate=0.1,
hidden_act="gelu",
hidden_dropout_prob=0.0,
hidden_size=768,
image_size=224,
initializer_range=0.02,
intermediate_size=3072,
layer_norm_eps=1e-12,
layer_scale_init_value=0.1,
num_attention_heads=12,
num_channels=3,
num_hidden_layers=12,
patch_size=16,
use_absolute_position_embeddings=False,
use_mask_token=False,
use_mean_pooling=True,
use_relative_position_bias=True,
use_shared_relative_position_bias=False,
)
model = BeitForImageClassification(config)
processor = BeitImageProcessor(
do_resize=True,
size={"height": 224, "width": 224},
resample=2,
do_rescale=True,
rescale_factor=1.0 / 255.0,
do_normalize=True,
do_center_crop=False,
crop_size={"height": 224, "width": 224},
do_reduce_labels=False,
image_mean=(0.5, 0.5, 0.5),
image_std=(0.5, 0.5, 0.5),
)
else:
config = ResNetConfig(
num_labels=num_labels,
depths=[3, 4, 6, 3],
downsample_in_first_stage=False,
embedding_size=64,
hidden_act="relu",
hidden_sizes=[256, 512, 1024, 2048],
layer_type="bottleneck",
num_channels=3,
out_features=["stage4"],
out_indices=[4],
)
model = ResNetForImageClassification(config)
processor = ConvNextImageProcessor(
do_resize=True,
size={"shortest_edge": 224},
resample=3,
do_rescale=True,
rescale_factor=1.0 / 255.0,
do_normalize=True,
image_mean=(0.485, 0.456, 0.406),
image_std=(0.229, 0.224, 0.225),
)
model = model.to(dtype=dtype)
model.load_state_dict(state, strict=True)
model.eval()
return _ImageClassifierEntry(
model=model,
processor=processor,
architecture=architecture,
num_labels=num_labels,
)
class _ImageClassifierCache:
def __init__(self, max_entries: int = 3) -> None:
self.max_entries = max_entries
self._entries: dict[tuple[Any, ...], _ImageClassifierEntry] = {}
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(path: str, architecture: str) -> tuple[Any, ...]:
status = os.stat(path)
return (
os.path.realpath(path), architecture,
status.st_dev, status.st_ino, status.st_size,
status.st_mtime_ns, status.st_ctime_ns,
)
def get(self, path: str, architecture: str) -> _ImageClassifierEntry:
key = self._key(path, architecture)
with self._lock:
entry = self._entries.get(key)
if entry is not None:
self.hits += 1
return entry
entry = _load_image_classifier_weight(path, architecture)
self.loads += 1
while len(self._entries) >= self.max_entries:
_key, evicted = self._entries.popitem()
with evicted.lock:
evicted.model.to("cpu")
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
with entry.lock:
entry.model.to("cpu")
return len(entries)
_IMAGE_CLASSIFIER_CACHE = _ImageClassifierCache()
@dataclass
class _TextEncoderEntry:
clip: Any
model_type: str
device: str
lock: threading.Lock = field(default_factory=threading.Lock)
def _load_text_encoder_weight(
path: str, model_type: str, device: str,
) -> _TextEncoderEntry:
import torch
import comfy.sd
import folder_paths
enum_name = model_type.replace("-", "_").upper()
clip_type = comfy.sd.CLIPType.__members__.get(enum_name)
if clip_type is None:
raise ValueError(f"unknown Comfy text-encoder type {model_type!r}")
model_options = {}
if device == "cpu":
model_options["load_device"] = torch.device("cpu")
model_options["offload_device"] = torch.device("cpu")
clip = comfy.sd.load_clip(
ckpt_paths=[path],
embedding_directory=folder_paths.get_folder_paths("embeddings"),
clip_type=clip_type,
model_options=model_options,
)
if not callable(getattr(clip, "tokenize", None)):
raise ValueError("the selected weight is not a Comfy text encoder")
return _TextEncoderEntry(
clip=clip, model_type=model_type, device=device)
class _TextEncoderCache:
def __init__(self, max_entries: int = 2) -> None:
self.max_entries = max_entries
self._entries: OrderedDict[tuple[Any, ...], _TextEncoderEntry] = (
OrderedDict())
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(
path: str, model_type: str, device: str,
) -> tuple[Any, ...]:
status = os.stat(path)
return (
os.path.realpath(path), model_type, device,
status.st_dev, status.st_ino, status.st_size,
status.st_mtime_ns, status.st_ctime_ns,
)
def get(
self, path: str, model_type: str, device: str,
) -> _TextEncoderEntry:
key = self._key(path, model_type, device)
with self._lock:
entry = self._entries.pop(key, None)
if entry is not None:
self.hits += 1
self._entries[key] = entry
return entry
entry = _load_text_encoder_weight(path, model_type, device)
self.loads += 1
while len(self._entries) >= self.max_entries:
self._entries.popitem(last=False)
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
count = len(self._entries)
self._entries.clear()
return count
_TEXT_ENCODER_CACHE = _TextEncoderCache()
_QWEN_LANGUAGE_FAMILIES = frozenset({
"qwen3_vl_2b",
"qwen3_vl_4b",
"qwen3_vl_8b",
"qwen3_vl_32b",
"qwen2_5_vl_3b",
"qwen2_5_vl_7b",
"qwen3_0_6b",
"qwen3_4b",
})
def _dequant_qwen_block_fp8(state_dict: dict[str, Any]) -> None:
"""Materialize official Qwen per-128 block FP8 weights safely.
Native Comfy text encoders do not yet consume the official
``weight_scale_inv`` layout. Dequantizing once during the trusted load
preserves model semantics without exposing a low-level quantization API to
node packs.
"""
import torch
scale_keys = [
key for key in state_dict if key.endswith(".weight_scale_inv")
]
for scale_key in scale_keys:
weight_key = scale_key[:-len("_scale_inv")]
weight = state_dict.get(weight_key)
scale = state_dict.get(scale_key)
if not isinstance(weight, torch.Tensor) or not isinstance(scale, torch.Tensor):
raise ValueError("Qwen FP8 scale is missing its tensor weight")
if weight.ndim != 2 or scale.ndim != 2:
raise ValueError("Qwen FP8 block weights must be two-dimensional")
expected = (
(weight.shape[0] + 127) // 128,
(weight.shape[1] + 127) // 128,
)
if tuple(scale.shape) != expected:
raise ValueError(
f"Qwen FP8 scale shape {tuple(scale.shape)} does not match "
f"weight shape {tuple(weight.shape)}")
# Materialize one 128-row block at a time. Expanding every block
# scale to a full FP32 matrix would transiently add several copies of
# a 32B model's largest weights and can OOM before Comfy can offload.
dequantized = torch.empty(
weight.shape, device=weight.device, dtype=torch.bfloat16)
for row_block in range(scale.shape[0]):
start = row_block * 128
end = min(start + 128, weight.shape[0])
row_scale = scale[row_block].float().repeat_interleave(128)
row_scale = row_scale[:weight.shape[1]].unsqueeze(0)
dequantized[start:end].copy_(
(weight[start:end].float() * row_scale).to(torch.bfloat16))
state_dict[weight_key] = dequantized
del state_dict[scale_key]
def _load_qwen_language_model(
paths: tuple[str, ...], family: str, device: str,
) -> _TextEncoderEntry:
import torch
import comfy.sd
import comfy.text_encoders.hunyuan_video
import comfy.text_encoders.qwen3vl
import comfy.text_encoders.qwen_image
import comfy.text_encoders.qwen_generation
import comfy.utils
import folder_paths
state_dict: dict[str, Any] = {}
parameters = 0
for path in paths:
shard, metadata = comfy.utils.load_torch_file(
path, safe_load=True, return_metadata=True)
shard, _ = comfy.utils.convert_old_quants(
shard, model_prefix="", metadata=metadata)
duplicate = state_dict.keys() & shard.keys()
if duplicate:
raise ValueError(
f"Qwen SafeTensor shards contain duplicate key "
f"{next(iter(duplicate))!r}")
state_dict.update(shard)
parameters += comfy.utils.calculate_parameters(shard)
_dequant_qwen_block_fp8(state_dict)
normalized = {}
for key, value in state_dict.items():
if key.startswith("model.language_model."):
key = "model." + key[len("model.language_model."):]
elif key.startswith("model.visual."):
key = "visual." + key[len("model.visual."):]
elif key.startswith("lm_head."):
key = "model.lm_head." + key[len("lm_head."):]
normalized[key] = value
state_dict = normalized
class Target:
params = {}
detect_options = comfy.text_encoders.hunyuan_video.llama_detect(state_dict)
model_options = {}
if device == "cpu":
model_options["load_device"] = torch.device("cpu")
model_options["offload_device"] = torch.device("cpu")
has_lm_head = "model.lm_head.weight" in state_dict
internal_family = (
family.replace("qwen3_vl_", "qwen3vl_", 1)
if family.startswith("qwen3_vl_")
else family
)
model_options[f"{internal_family}_model_config"] = {
"lm_head": has_lm_head,
}
if family.startswith("qwen3_vl_"):
Target.clip = comfy.text_encoders.qwen3vl.te(
**detect_options, model_type=internal_family)
Target.tokenizer = comfy.text_encoders.qwen3vl.generation_tokenizer(
model_type=internal_family)
elif family.startswith("qwen2_5_vl_"):
Target.clip = comfy.text_encoders.qwen_image.vl_te(
**detect_options, model_type=family)
Target.tokenizer = comfy.text_encoders.qwen_image.vl_tokenizer(
model_type=family)
else:
Target.clip = comfy.text_encoders.qwen_generation.te(
**detect_options, model_type=family)
Target.tokenizer = comfy.text_encoders.qwen_generation.tokenizer(
model_type=family)
clip = comfy.sd.CLIP(
Target,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
parameters=parameters,
state_dict=[state_dict],
model_options=model_options,
)
if not callable(getattr(clip, "generate", None)):
raise ValueError("the selected Qwen weights do not support generation")
return _TextEncoderEntry(
clip=clip, model_type=family, device=device)
class _LanguageModelCache:
def __init__(self, max_entries: int = 1) -> None:
self.max_entries = max_entries
self._entries: OrderedDict[tuple[Any, ...], _TextEncoderEntry] = (
OrderedDict())
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(
paths: tuple[str, ...], family: str, device: str,
) -> tuple[Any, ...]:
files = []
for path in paths:
status = os.stat(path)
files.append((
os.path.realpath(path), status.st_dev, status.st_ino,
status.st_size, status.st_mtime_ns, status.st_ctime_ns,
))
return family, device, tuple(files)
def get(
self, paths: tuple[str, ...], family: str, device: str,
cache: bool,
) -> _TextEncoderEntry:
if not cache:
self.loads += 1
return _load_qwen_language_model(paths, family, device)
key = self._key(paths, family, device)
with self._lock:
entry = self._entries.pop(key, None)
if entry is not None:
self.hits += 1
self._entries[key] = entry
return entry
entry = _load_qwen_language_model(paths, family, device)
self.loads += 1
while len(self._entries) >= self.max_entries:
self._entries.popitem(last=False)
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
count = len(self._entries)
self._entries.clear()
return count
_LANGUAGE_MODEL_CACHE = _LanguageModelCache()
@dataclass
class _SegformerEntry:
model: Any
variant: str
num_labels: int
lock: threading.Lock = field(default_factory=threading.Lock)
def _load_segformer_weight(
path: str, variant: str, num_labels: int,
) -> _SegformerEntry:
try:
from safetensors.torch import load_file
from transformers import SegformerConfig, SegformerForSemanticSegmentation
except ImportError as exc:
raise RuntimeError(
"SegFormer semantic segmentation requires transformers and "
"safetensors") from exc
depths = {
"b2": [3, 4, 6, 3],
"b3": [3, 4, 18, 3],
"b5": [3, 6, 40, 3],
}.get(variant)
if depths is None:
raise ValueError("SegFormer variant must be b2, b3, or b5")
config = SegformerConfig(
num_labels=num_labels,
num_channels=3,
depths=depths,
hidden_sizes=[64, 128, 320, 512],
decoder_hidden_size=768,
patch_sizes=[7, 3, 3, 3],
strides=[4, 2, 2, 2],
num_attention_heads=[1, 2, 5, 8],
mlp_ratios=[4, 4, 4, 4],
sr_ratios=[8, 4, 2, 1],
hidden_act="gelu",
hidden_dropout_prob=0.0,
attention_probs_dropout_prob=0.0,
classifier_dropout_prob=0.1,
drop_path_rate=0.1,
reshape_last_stage=True,
semantic_loss_ignore_index=255,
)
model = SegformerForSemanticSegmentation(config)
state = load_file(path, device="cpu")
model_state = model.state_dict()
if set(state) != set(model_state):
try:
from transformers.conversion_mapping import (
get_model_conversion_mapping,
)
from transformers.core_model_loading import (
WeightRenaming,
rename_source_key,
)
except ImportError as exc:
raise ValueError(
"SegFormer weights do not match the installed Transformers "
"version") from exc
conversions = get_model_conversion_mapping(
model, add_legacy=False)
if not conversions or any(
not isinstance(item, WeightRenaming) for item in conversions
):
raise ValueError(
"SegFormer checkpoint conversion is not a pure key rename")
converted = {}
for key, value in state.items():
renamed, _pattern = rename_source_key(
key, conversions, [], model.base_model_prefix, model_state)
if renamed in converted:
raise ValueError(
"SegFormer checkpoint conversion produced duplicate keys")
converted[renamed] = value
state = converted
model.load_state_dict(state, strict=True)
model.eval()
model.to("cpu")
return _SegformerEntry(
model=model, variant=variant, num_labels=num_labels)
class _SegformerCache:
def __init__(self, max_entries: int = 2) -> None:
self.max_entries = max_entries
self._entries: OrderedDict[tuple[Any, ...], _SegformerEntry] = (
OrderedDict())
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(
path: str, variant: str, num_labels: int,
) -> tuple[Any, ...]:
status = os.stat(path)
return (
os.path.realpath(path), variant, num_labels,
status.st_dev, status.st_ino, status.st_size,
status.st_mtime_ns, status.st_ctime_ns,
)
def get(
self, path: str, variant: str, num_labels: int,
) -> _SegformerEntry:
key = self._key(path, variant, num_labels)
with self._lock:
entry = self._entries.pop(key, None)
if entry is not None:
self.hits += 1
self._entries[key] = entry
return entry
entry = _load_segformer_weight(path, variant, num_labels)
self.loads += 1
while len(self._entries) >= self.max_entries:
_old_key, old = self._entries.popitem(last=False)
with old.lock:
old.model.to("cpu")
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
with entry.lock:
entry.model.to("cpu")
return len(entries)
_SEGFORMER_CACHE = _SegformerCache()
@dataclass
class _VitMatteEntry:
model: Any
variant: str
lock: threading.Lock = field(default_factory=threading.Lock)
def _load_vitmatte_weight(path: str, variant: str) -> _VitMatteEntry:
try:
from transformers import (
VitDetConfig,
VitMatteConfig,
VitMatteForImageMatting,
)
except ImportError as exc:
raise RuntimeError("ViTMatte requires Transformers") from exc
import torch
import comfy.utils
parameters = {
"small": (384, 6),
"base": (768, 12),
}
if variant not in parameters:
raise ValueError("ViTMatte variant must be small or base")
hidden_size, attention_heads = parameters[variant]
backbone = VitDetConfig(
hidden_size=hidden_size,
num_attention_heads=attention_heads,
image_size=512,
num_channels=4,
_out_features=["stage12"],
_out_indices=[12],
residual_block_indices=[2, 5, 8, 11],
use_relative_position_embeddings=True,
window_block_indices=[0, 1, 3, 4, 6, 7, 9, 10],
window_size=14,
)
config = VitMatteConfig(
backbone_config=backbone,
hidden_size=hidden_size,
convstream_hidden_sizes=[48, 96, 192],
fusion_hidden_sizes=[256, 128, 64, 32],
)
state = comfy.utils.load_torch_file(path, safe_load=True)
if (
not isinstance(state, dict)
or not state
or any(
not isinstance(key, str) or not isinstance(value, torch.Tensor)
for key, value in state.items()
)
):
raise ValueError("ViTMatte weights must contain only tensors")
model = VitMatteForImageMatting(config)
model.load_state_dict(state, strict=True)
model.eval()
model.to("cpu")
return _VitMatteEntry(model=model, variant=variant)
class _VitMatteCache:
def __init__(self, max_entries: int = 2) -> None:
self.max_entries = max_entries
self._entries: OrderedDict[tuple[Any, ...], _VitMatteEntry] = (
OrderedDict())
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(path: str, variant: str) -> tuple[Any, ...]:
status = os.stat(path)
return (
os.path.realpath(path), variant,
status.st_dev, status.st_ino, status.st_size,
status.st_mtime_ns, status.st_ctime_ns,
)
def get(self, path: str, variant: str) -> _VitMatteEntry:
key = self._key(path, variant)
with self._lock:
entry = self._entries.pop(key, None)
if entry is not None:
self.hits += 1
self._entries[key] = entry
return entry
entry = _load_vitmatte_weight(path, variant)
self.loads += 1
while len(self._entries) >= self.max_entries:
_old_key, old = self._entries.popitem(last=False)
with old.lock:
old.model.to("cpu")
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
with entry.lock:
entry.model.to("cpu")
return len(entries)
_VITMATTE_CACHE = _VitMatteCache()
@dataclass
class _VqaEntry:
model: Any
tokenizer: Any
architecture: str
lock: threading.Lock = field(default_factory=threading.Lock)
@dataclass
class _VqaModelValue:
entry: _VqaEntry
precision: str
device: str
def _load_vqa_weight(path: str, architecture: str) -> _VqaEntry:
try:
from transformers import (
BertTokenizer,
BlipConfig,
BlipForQuestionAnswering,
)
except ImportError as exc:
raise RuntimeError("BLIP visual question answering requires Transformers") from exc
import torch
import comfy.utils
if architecture not in {
"blip-vqa-base", "blip-vqa-capfilt-large",
}:
raise ValueError("unknown BLIP VQA architecture")
text_config = {
"attention_probs_dropout_prob": 0.0,
"bos_token_id": 30522,
"encoder_hidden_size": 768,
"eos_token_id": 2,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.0,
"hidden_size": 768,
"initializer_range": 0.02,
"intermediate_size": 3072,
"is_decoder": True,
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"num_attention_heads": 12,
"num_hidden_layers": 12,
"pad_token_id": 0,
"projection_dim": 768,
"sep_token_id": 102,
"use_cache": True,
"vocab_size": 30524,
}
vision_config = {
"attention_dropout": 0.0,
"dropout": 0.0,
"hidden_act": "gelu",
"hidden_size": 768,
"image_size": 384,
"initializer_range": 0.02,
"intermediate_size": 3072,
"layer_norm_eps": 1e-5,
"num_attention_heads": 12,
"num_channels": 3,
"num_hidden_layers": 12,
"patch_size": 16,
"projection_dim": 512,
}
config = BlipConfig(
text_config=text_config,
vision_config=vision_config,
projection_dim=512,
image_text_hidden_size=256,
)
state = comfy.utils.load_torch_file(path, safe_load=True)
if (
not isinstance(state, dict)
or not state
or any(
not isinstance(key, str) or not isinstance(value, torch.Tensor)
for key, value in state.items()
)
):
raise ValueError("BLIP VQA weights must contain only tensors")
model = BlipForQuestionAnswering(config)
incompatible = model.load_state_dict(state, strict=False)
allowed_missing = {"text_decoder.cls.predictions.decoder.bias"}
allowed_unexpected = {
"text_decoder.bert.embeddings.position_ids",
"text_encoder.embeddings.position_ids",
}
if (
set(incompatible.missing_keys) - allowed_missing
or set(incompatible.unexpected_keys) - allowed_unexpected
):
raise ValueError("BLIP VQA weights do not match the fixed architecture")
model.eval()
model.to("cpu")
vocab = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"comfy", "text_encoders", "blip_tokenizer", "vocab.txt",
)
if not os.path.isfile(vocab):
raise RuntimeError("the bundled BLIP tokenizer vocabulary is unavailable")
tokenizer = BertTokenizer(vocab=vocab, do_lower_case=True)
if len(tokenizer) != 30522:
raise RuntimeError("the bundled BLIP tokenizer vocabulary is invalid")
return _VqaEntry(
model=model, tokenizer=tokenizer, architecture=architecture)
class _VqaCache:
def __init__(self, max_entries: int = 1) -> None:
self.max_entries = max_entries
self._entries: OrderedDict[tuple[Any, ...], _VqaEntry] = OrderedDict()
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(path: str, architecture: str) -> tuple[Any, ...]:
status = os.stat(path)
return (
os.path.realpath(path), architecture,
status.st_dev, status.st_ino, status.st_size,
status.st_mtime_ns, status.st_ctime_ns,
)
def get(self, path: str, architecture: str) -> _VqaEntry:
key = self._key(path, architecture)
with self._lock:
entry = self._entries.pop(key, None)
if entry is not None:
self.hits += 1
self._entries[key] = entry
return entry
entry = _load_vqa_weight(path, architecture)
self.loads += 1
while len(self._entries) >= self.max_entries:
_old_key, old = self._entries.popitem(last=False)
with old.lock:
old.model.to("cpu")
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
with entry.lock:
entry.model.to("cpu")
return len(entries)
_VQA_CACHE = _VqaCache()
def _validate_onnx_weight_file(path: str) -> None:
"""Admit only one self-contained graph made from standard ONNX domains."""
try:
import onnx
except ImportError as exc:
raise RuntimeError(
"ONNX model validation requires the onnx package") from exc
maximum = int(os.environ.get(
"COMFY_SECURE_ONNX_WEIGHT_MAX", str(4 * 1024 * 1024 * 1024)))
size = os.path.getsize(path)
if maximum <= 0 or not 1 <= size <= maximum:
raise ValueError("ONNX model file is outside the configured size limit")
try:
model = onnx.load(path, load_external_data=False)
except Exception as exc:
raise ValueError("download is not a valid ONNX model") from exc
if model.functions or model.training_info:
raise ValueError("ONNX model functions and training graphs are not allowed")
allowed_domains = {"", "ai.onnx", "ai.onnx.ml"}
# Exporters commonly leave unused provider-specific opset declarations in
# otherwise standard graphs. They carry no executable behavior; enforce
# the domain boundary on every actual node below.
if any(
not isinstance(item.domain, str) or len(item.domain) > 256
or not 1 <= item.version <= 2**31 - 1
for item in model.opset_import
):
raise ValueError("ONNX model has an invalid operator-set declaration")
node_count = 0
tensor_count = 0
graph_count = 0
def check_tensor(tensor: Any) -> None:
nonlocal tensor_count
tensor_count += 1
if tensor_count > 200_000:
raise ValueError("ONNX model has too many tensors")
if (tensor.data_location == onnx.TensorProto.EXTERNAL
or len(tensor.external_data)):
raise ValueError("external ONNX tensor data is not allowed")
if len(tensor.dims) > 16 or any(
dimension < 0 or dimension > 2**31 - 1
for dimension in tensor.dims):
raise ValueError("ONNX tensor dimensions are invalid")
def check_graph(graph: Any) -> None:
nonlocal graph_count, node_count
graph_count += 1
if graph_count > 1024:
raise ValueError("ONNX model has too many nested graphs")
for tensor in graph.initializer:
check_tensor(tensor)
for sparse in graph.sparse_initializer:
check_tensor(sparse.values)
check_tensor(sparse.indices)
for node in graph.node:
node_count += 1
if node_count > 200_000:
raise ValueError("ONNX model has too many operators")
if node.domain not in allowed_domains:
raise ValueError(
"ONNX model uses a non-standard operator domain")
if not node.op_type or len(node.op_type) > 128:
raise ValueError("ONNX model contains an invalid operator")
for attribute in node.attribute:
if attribute.HasField("t"):
check_tensor(attribute.t)
for tensor in attribute.tensors:
check_tensor(tensor)
if attribute.HasField("g"):
check_graph(attribute.g)
for nested in attribute.graphs:
check_graph(nested)
check_graph(model.graph)
if node_count == 0 or tensor_count == 0:
raise ValueError("ONNX model contains no executable weighted graph")
try:
onnx.checker.check_model(model, full_check=False)
except Exception as exc:
raise ValueError("ONNX model failed structural validation") from exc
@dataclass
class _OnnxImageClassifierEntry:
session: Any
input_name: str
output_name: str
input_height: int
input_width: int
class_count: int
input_layouts: frozenset[str]
lock: threading.Lock = field(default_factory=threading.Lock)
def _load_onnx_image_classifier(path: str) -> _OnnxImageClassifierEntry:
_validate_onnx_weight_file(path)
try:
import onnxruntime as ort
except ImportError as exc:
raise RuntimeError(
"ONNX image classification requires onnxruntime") from exc
options = ort.SessionOptions()
options.log_severity_level = 3
available = set(ort.get_available_providers())
providers = [
provider for provider in (
"CUDAExecutionProvider", "CPUExecutionProvider")
if provider in available
]
if not providers:
raise RuntimeError("ONNX Runtime has no supported execution provider")
try:
session = ort.InferenceSession(
path, sess_options=options, providers=providers)
except Exception as exc:
raise ValueError("ONNX image classifier could not be loaded") from exc
inputs = session.get_inputs()
outputs = session.get_outputs()
if len(inputs) != 1 or len(outputs) != 1:
raise ValueError("ONNX image classifier must have one input and output")
model_input = inputs[0]
model_output = outputs[0]
if model_input.type != "tensor(float)" or model_output.type not in {
"tensor(float)", "tensor(float16)", "tensor(double)",
}:
raise ValueError("ONNX image classifier must use floating-point tensors")
input_shape = model_input.shape
output_shape = model_output.shape
if len(input_shape) != 4 or len(output_shape) != 2:
raise ValueError("ONNX image classifier has an invalid tensor rank")
# WD-style NHWC and common NCHW models are both admitted. The selected
# layout is checked again when the loader binds preprocessing options.
nhwc = input_shape[3] == 3
nchw = input_shape[1] == 3
if not nhwc and not nchw:
raise ValueError("ONNX image classifier must consume three channels")
if nhwc and nchw:
raise ValueError("ONNX classifier channel layout is ambiguous")
height = input_shape[1] if nhwc else input_shape[2]
width = input_shape[2] if nhwc else input_shape[3]
class_count = output_shape[1]
if (type(height) is not int or type(width) is not int
or not 1 <= height <= 4096 or not 1 <= width <= 4096):
raise ValueError("ONNX classifier spatial dimensions must be fixed")
if type(class_count) is not int or not 1 <= class_count <= 16_384:
raise ValueError("ONNX classifier output count is outside the safe range")
return _OnnxImageClassifierEntry(
session=session,
input_name=model_input.name,
output_name=model_output.name,
input_height=height,
input_width=width,
class_count=class_count,
input_layouts=frozenset(
layout for layout, valid in (("NHWC", nhwc), ("NCHW", nchw))
if valid),
)
class _OnnxImageClassifierCache:
def __init__(self, max_entries: int = 3) -> None:
self.max_entries = max_entries
self._entries: OrderedDict[tuple[Any, ...], _OnnxImageClassifierEntry] = (
OrderedDict())
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(path: str) -> tuple[Any, ...]:
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, path: str) -> _OnnxImageClassifierEntry:
key = self._key(path)
with self._lock:
entry = self._entries.pop(key, None)
if entry is not None:
self.hits += 1
self._entries[key] = entry
return entry
entry = _load_onnx_image_classifier(path)
self.loads += 1
while len(self._entries) >= self.max_entries:
self._entries.popitem(last=False)
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
count = len(self._entries)
self._entries.clear()
return count
_ONNX_IMAGE_CLASSIFIER_CACHE = _OnnxImageClassifierCache()
@dataclass
class _OnnxDetectorEntry:
model: Any
lock: threading.Lock = field(default_factory=threading.Lock)
def _load_onnx_detector(path: str) -> _OnnxDetectorEntry:
try:
import cv2
except ImportError as exc:
raise RuntimeError("ONNX detection requires OpenCV DNN") from exc
return _OnnxDetectorEntry(cv2.dnn.readNetFromONNX(path))
class _OnnxDetectorCache:
def __init__(self, max_entries: int = 2) -> None:
self.max_entries = max_entries
self._entries: dict[tuple[Any, ...], _OnnxDetectorEntry] = {}
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(path: str) -> tuple[Any, ...]:
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, path: str) -> _OnnxDetectorEntry:
key = self._key(path)
with self._lock:
entry = self._entries.get(key)
if entry is not None:
self.hits += 1
return entry
entry = _load_onnx_detector(path)
self.loads += 1
while len(self._entries) >= self.max_entries:
self._entries.popitem()
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
count = len(self._entries)
self._entries.clear()
return count
_ONNX_DETECTOR_CACHE = _OnnxDetectorCache()
@dataclass
class _SamEntry:
model: Any
architecture: str
lock: threading.Lock = field(default_factory=threading.Lock)
def _load_sam_weight(path: str, architecture: str) -> _SamEntry:
try:
from safetensors.torch import load_file
from segment_anything import sam_model_registry
except ImportError as exc:
raise RuntimeError(
"SAM models require segment-anything and safetensors") from exc
if architecture not in {"vit_b", "vit_l", "vit_h"}:
raise ValueError("SAM architecture must be vit_b, vit_l, or vit_h")
constructor = sam_model_registry.get(architecture)
if constructor is None:
raise RuntimeError(
f"segment-anything does not provide {architecture!r}")
model = constructor(checkpoint=None)
state = load_file(path, device="cpu")
model.load_state_dict(state, strict=True)
model.eval()
model.to("cpu")
return _SamEntry(model=model, architecture=architecture)
class _SamCache:
def __init__(self, max_entries: int = 2) -> None:
self.max_entries = max_entries
self._entries: dict[tuple[Any, ...], _SamEntry] = {}
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(path: str, architecture: str) -> tuple[Any, ...]:
status = os.stat(path)
return (
os.path.realpath(path), architecture,
status.st_dev, status.st_ino, status.st_size,
status.st_mtime_ns, status.st_ctime_ns,
)
def get(self, path: str, architecture: str) -> _SamEntry:
key = self._key(path, architecture)
with self._lock:
entry = self._entries.get(key)
if entry is not None:
self.hits += 1
return entry
entry = _load_sam_weight(path, architecture)
self.loads += 1
while len(self._entries) >= self.max_entries:
_key, evicted = self._entries.popitem()
with evicted.lock:
evicted.model.to("cpu")
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
with entry.lock:
entry.model.to("cpu")
return len(entries)
_SAM_CACHE = _SamCache()
_SAM2_CONFIGS = {
"sam2_hiera_tiny": "configs/sam2/sam2_hiera_t.yaml",
"sam2_hiera_small": "configs/sam2/sam2_hiera_s.yaml",
"sam2_hiera_base_plus": "configs/sam2/sam2_hiera_b+.yaml",
"sam2_hiera_large": "configs/sam2/sam2_hiera_l.yaml",
"sam2.1_hiera_tiny": "configs/sam2.1/sam2.1_hiera_t.yaml",
"sam2.1_hiera_small": "configs/sam2.1/sam2.1_hiera_s.yaml",
"sam2.1_hiera_base_plus": "configs/sam2.1/sam2.1_hiera_b+.yaml",
"sam2.1_hiera_large": "configs/sam2.1/sam2.1_hiera_l.yaml",
}
def _load_sam2_weight(path: str, architecture: str) -> _SamEntry:
try:
from safetensors.torch import load_file
from sam2.build_sam import build_sam2_video_predictor
except ImportError as exc:
raise RuntimeError(
"SAM2 models require sam2 and safetensors") from exc
config = _SAM2_CONFIGS.get(architecture)
if config is None:
raise ValueError("unknown SAM2 architecture")
model = build_sam2_video_predictor(
config, ckpt_path=None, device="cpu")
model.load_state_dict(load_file(path, device="cpu"), strict=True)
model.eval()
return _SamEntry(model=model, architecture=architecture)
class _Sam2Cache(_SamCache):
def get(self, path: str, architecture: str) -> _SamEntry:
key = self._key(path, architecture)
with self._lock:
entry = self._entries.get(key)
if entry is not None:
self.hits += 1
return entry
entry = _load_sam2_weight(path, architecture)
self.loads += 1
while len(self._entries) >= self.max_entries:
_key, evicted = self._entries.popitem()
with evicted.lock:
evicted.model.to("cpu")
self._entries[key] = entry
return entry
_SAM2_CACHE = _Sam2Cache(max_entries=1)
@dataclass
class _TransparentVaeDecoderEntry:
decoder: Any
family: str
lock: threading.Lock = field(default_factory=threading.Lock)
def _load_transparent_vae_decoder_weight(
path: str, family: str,
) -> _TransparentVaeDecoderEntry:
import nodes
import comfy.model_management
import comfy.utils
node_class = getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get(
"LayeredDiffusionDecode")
module = (
None if node_class is None
else sys.modules.get(getattr(node_class, "__module__", ""))
)
decoder_class = getattr(module, "TransparentVAEDecoder", None)
if not callable(decoder_class):
raise RuntimeError(
"transparent VAE decoding requires the host-installed canonical "
"ComfyUI-layerdiffuse extension")
state = comfy.utils.load_torch_file(path, safe_load=True)
if not isinstance(state, dict) or not state:
raise ValueError(
"transparent VAE decoder weights must be a non-empty SafeTensor "
"state dict")
import torch
if any(
not isinstance(key, str) or not isinstance(value, torch.Tensor)
for key, value in state.items()
):
raise ValueError(
"transparent VAE decoder weights must contain only tensors")
decoder = decoder_class(
state,
device=comfy.model_management.get_torch_device(),
dtype=(
torch.float16
if comfy.model_management.should_use_fp16()
else torch.float32
),
)
return _TransparentVaeDecoderEntry(decoder=decoder, family=family)
class _TransparentVaeDecoderCache:
def __init__(self, max_entries: int = 2) -> None:
self.max_entries = max_entries
self._entries: OrderedDict[
tuple[Any, ...], _TransparentVaeDecoderEntry
] = OrderedDict()
self._lock = threading.Lock()
self.loads = 0
self.hits = 0
@staticmethod
def _key(path: str, family: str) -> tuple[Any, ...]:
status = os.stat(path)
return (
os.path.realpath(path), family,
status.st_dev, status.st_ino, status.st_size,
status.st_mtime_ns, status.st_ctime_ns,
)
def get(self, path: str, family: str) -> _TransparentVaeDecoderEntry:
key = self._key(path, family)
with self._lock:
entry = self._entries.pop(key, None)
if entry is not None:
self.hits += 1
self._entries[key] = entry
return entry
entry = _load_transparent_vae_decoder_weight(path, family)
self.loads += 1
while len(self._entries) >= self.max_entries:
self._entries.popitem(last=False)
self._entries[key] = entry
return entry
def clear(self) -> int:
with self._lock:
count = len(self._entries)
self._entries.clear()
return count
_TRANSPARENT_VAE_DECODER_CACHE = _TransparentVaeDecoderCache()
def _advanced_control_module(relative: str):
"""Resolve a fixed module from the installed Advanced-ControlNet pack.
Resolution is anchored to one of that pack's registered node classes; no
guest-controlled module name or path participates in the import.
"""
import importlib
import nodes
mappings = getattr(nodes, "NODE_CLASS_MAPPINGS", {})
for node_id in (
"ACN_AdvancedControlNetApply",
"ACN_ControlNet++LoaderSingle",
"ACN_ScaledSoftControlNetWeights",
"ControlNetLoaderAdvanced",
"ScaledSoftControlNetWeights",
):
node_class = mappings.get(node_id)
module_name = getattr(node_class, "__module__", "")
parts = module_name.split(".")
if "adv_control" not in parts:
continue
base = ".".join(parts[:parts.index("adv_control") + 1])
return importlib.import_module(f"{base}.{relative}")
for base in ("ComfyUI-Advanced-ControlNet.adv_control", "adv_control"):
try:
package = importlib.import_module(base)
except ModuleNotFoundError:
continue
candidate = getattr(package, relative, None)
if candidate is not None:
return candidate
try:
return importlib.import_module(f"{base}.{relative}")
except ModuleNotFoundError:
continue
raise RuntimeError(
"this operation requires the host-installed "
"ComfyUI-Advanced-ControlNet extension")
def _fixed_gguf_node_module():
import folder_paths
import nodes
node_class = nodes.NODE_CLASS_MAPPINGS.get("UnetLoaderGGUF")
module = (
None if node_class is None
else sys.modules.get(getattr(node_class, "__module__", ""))
)
if module is None:
raise RuntimeError(
"GGUF model loading requires the fixed ComfyUI-GGUF extension; "
"install or update https://github.com/city96/ComfyUI-GGUF")
module_file = getattr(module, "__file__", None)
custom_roots = folder_paths.folder_names_and_paths.get(
"custom_nodes", ([], set()))[0]
if isinstance(custom_roots, str):
custom_roots = [custom_roots]
allowed_roots = [
os.path.realpath(os.path.join(root, folder))
for root in custom_roots
for folder in ("ComfyUI-GGUF", "comfyui-gguf")
]
candidate = None if module_file is None else os.path.realpath(module_file)
def allowed(root: str) -> bool:
if candidate is None:
return False
try:
return os.path.commonpath((root, candidate)) == root
except ValueError:
return False
if not any(allowed(root) for root in allowed_roots):
raise RuntimeError(
"the registered GGUF loader is not the fixed ComfyUI-GGUF module")
for name in ("GGMLOps", "gguf_sd_loader", "GGUFModelPatcher"):
if not hasattr(module, name):
raise RuntimeError(
"the installed ComfyUI-GGUF extension is incompatible: "
f"missing {name}")
return module
class _InProcessModels:
_HF_ENDPOINT = "https://huggingface.co"
_HF_PYTORCH_WEIGHT_EXTENSIONS = frozenset({
".bin", ".ckpt", ".patch", ".pt", ".pth",
})
_HF_WEIGHT_EXTENSIONS = frozenset({
".safetensors", ".sft", ".gguf", ".onnx",
*_HF_PYTORCH_WEIGHT_EXTENSIONS,
})
_HF_WEIGHT_FOLDERS = frozenset({
"audio_encoders",
"background_removal",
"checkpoints",
"clip_vision",
"controlnet",
"detection",
"diffusion_models",
"embeddings",
"frame_interpolation",
"geometry_estimation",
"gligen",
"hypernetworks",
"ipadapter",
"inpaint",
"latent_upscale_models",
"loras",
"model_patches",
"optical_flow",
"onnx",
"photomaker",
"sams",
"semantic_segmentation",
"style_models",
"text_encoders",
"unet_gguf",
"upscale_models",
"vae",
"vae_approx",
})
_HF_DOWNLOAD_LOCK = threading.Lock()
_HF_VERIFIED_WEIGHTS: dict[
str, tuple[int, int, int, int, int, Optional[str]]
] = {}
_WEIGHT_DTYPES = frozenset({
"default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2",
"fp16", "bf16", "fp32",
})
_COMPUTE_DTYPES = frozenset({"default", "fp16", "bf16", "fp32"})
@staticmethod
def _hf_repo_id(repo_id: str) -> str:
import re
if not isinstance(repo_id, str):
raise TypeError("Hugging Face repo_id must be a string")
if len(repo_id) > 96 or repo_id.startswith(("http:", "https:")):
raise ValueError("Hugging Face repo_id must name a model repository")
parts = repo_id.split("/")
component = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
if (len(parts) not in (1, 2)
or any(not component.fullmatch(part) for part in parts)
or any(part.endswith(("-", ".")) for part in parts)
or "--" in repo_id or ".." in repo_id
or repo_id.endswith(".git")):
raise ValueError("Hugging Face repo_id must name a model repository")
return repo_id
@classmethod
def _hf_weight_filename(cls, filename: str) -> tuple[str, str]:
import re
if not isinstance(filename, str):
raise TypeError("Hugging Face weight filename must be a string")
logical = filename.replace("\\", "/")
parts = logical.split("/")
component = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
if (not logical or len(logical) > 1024 or logical.startswith("/")
or any(len(part) > 255 or not component.fullmatch(part)
for part in parts)):
raise ValueError(
"Hugging Face weight filename must be a confined repository path")
extension = os.path.splitext(parts[-1])[1].lower()
if extension not in cls._HF_WEIGHT_EXTENSIONS:
raise ValueError(
"Hugging Face downloads are limited to .safetensors, .sft, "
".gguf, validated .onnx, and restricted PyTorch "
"tensor-archive weights")
return logical, extension
@staticmethod
def _hf_revision(revision: str) -> str:
import re
if not isinstance(revision, str):
raise TypeError("Hugging Face revision must be a string")
if (not revision or len(revision) > 200
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", revision)
or revision.endswith("/")
or any(part in ("", ".", "..")
for part in revision.split("/"))):
raise ValueError("Hugging Face revision is invalid")
return revision
@staticmethod
def _hf_sha256(sha256: Optional[str]) -> Optional[str]:
import re
if sha256 is None:
return None
if not isinstance(sha256, str) or not re.fullmatch(
r"[0-9a-fA-F]{64}", sha256):
raise ValueError("Hugging Face weight sha256 must be 64 hex digits")
return sha256.lower()
@classmethod
def _hf_weight_destination(
cls, folder: str, repo_id: str, revision: str,
filename: str, extension: str,
) -> tuple[str, str]:
import folder_paths
if not isinstance(folder, str) or folder not in cls._HF_WEIGHT_FOLDERS:
raise ValueError(
"Hugging Face weights must target a known model catalogue")
registered = folder_paths.folder_names_and_paths.get(folder)
if registered is None or not registered[0]:
raise ValueError(f"model catalogue {folder!r} is not registered")
extensions = {str(value).lower() for value in registered[1]}
if extension not in extensions:
raise ValueError(
f"model catalogue {folder!r} does not accept {extension} weights")
root = os.path.realpath(os.path.abspath(registered[0][0]))
os.makedirs(root, exist_ok=True)
logical = f"huggingface/{repo_id}/{revision}/{filename}"
destination = _InProcessAssets._confined_path(root, logical, folder)
return logical, destination
@staticmethod
def _verify_weight_file(path: str, extension: str) -> None:
if extension in (".safetensors", ".sft"):
from safetensors import safe_open
try:
with safe_open(path, framework="pt", device="cpu") as weights:
keys = list(weights.keys())
except Exception as exc:
raise ValueError("download is not a valid SafeTensors weight file") from exc
if not keys:
raise ValueError("SafeTensors file contains no weights")
return
if extension == ".onnx":
_validate_onnx_weight_file(path)
return
if extension in _InProcessModels._HF_PYTORCH_WEIGHT_EXTENSIONS:
import collections.abc
import torch
try:
value = torch.load(
path, map_location="cpu", mmap=True, weights_only=True)
except Exception as exc:
raise ValueError(
"download is not a restricted PyTorch weight archive"
) from exc
tensors = 0
entries = 0
stack = [value]
seen: set[int] = set()
while stack:
item = stack.pop()
entries += 1
if entries > 10_000_000:
raise ValueError("PyTorch weight archive is too complex")
if isinstance(item, torch.Tensor):
tensors += 1
continue
if item is None or isinstance(
item, (bool, int, float, str, bytes)
):
continue
identity = id(item)
if identity in seen:
continue
seen.add(identity)
if isinstance(item, collections.abc.Mapping):
stack.extend(item.keys())
stack.extend(item.values())
continue
if isinstance(item, (list, tuple)):
stack.extend(item)
continue
raise ValueError(
"PyTorch weight archive contains non-weight objects")
if tensors == 0:
raise ValueError("PyTorch weight archive contains no tensors")
return
import struct
with open(path, "rb") as file:
header = file.read(24)
if len(header) != 24 or header[:4] != b"GGUF":
raise ValueError("download is not a supported GGUF weight file")
version = struct.unpack("<I", header[4:8])[0]
tensor_count, metadata_count = struct.unpack("<QQ", header[8:24])
if (version not in (2, 3) or not 1 <= tensor_count <= 10_000_000
or metadata_count > 10_000_000):
raise ValueError("download is not a supported GGUF weight file")
@staticmethod
def _file_sha256(path: str) -> str:
import hashlib
digest = hashlib.sha256()
with open(path, "rb") as file:
for chunk in iter(lambda: file.read(8 * 1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@staticmethod
def _weight_identity(path: str) -> tuple[int, int, int, int, int]:
status = os.stat(path)
return (
status.st_dev,
status.st_ino,
status.st_size,
status.st_mtime_ns,
status.st_ctime_ns,
)
@classmethod
def _download_huggingface_weights(
cls, repo_id: str, filename: str, folder: str,
revision: str, sha256: Optional[str],
) -> str:
import inspect
import shutil
import tempfile
repo_id = cls._hf_repo_id(repo_id)
filename, extension = cls._hf_weight_filename(filename)
revision = cls._hf_revision(revision)
sha256 = cls._hf_sha256(sha256)
if extension == ".onnx" and sha256 is None:
raise ValueError("Hugging Face ONNX weights require a sha256 pin")
logical, destination = cls._hf_weight_destination(
folder, repo_id, revision, filename, extension)
request = {
"repo_id": repo_id,
"filename": filename,
"repo_type": "model",
"revision": revision,
"endpoint": cls._HF_ENDPOINT,
"token": False,
}
with cls._HF_DOWNLOAD_LOCK:
if os.path.isfile(destination):
try:
identity = cls._weight_identity(destination)
except OSError:
identity = None
if identity is not None:
cached = cls._HF_VERIFIED_WEIGHTS.get(destination)
if (cached is not None and cached[:5] == identity
and (sha256 is None or cached[5] == sha256)):
return logical
try:
cls._verify_weight_file(destination, extension)
digest_matches = (
sha256 is None
or cls._file_sha256(destination) == sha256)
except (OSError, ValueError):
digest_matches = False
if digest_matches:
cls._HF_VERIFIED_WEIGHTS[destination] = (
*identity, sha256)
return logical
try:
from huggingface_hub import hf_hub_download
except ImportError as exc:
raise RuntimeError(
"Hugging Face weight downloads require "
"huggingface_hub") from exc
if "dry_run" not in inspect.signature(
hf_hub_download).parameters:
raise RuntimeError(
"huggingface_hub is too old for bounded weight downloads")
max_bytes = int(os.environ.get(
"COMFY_SECURE_HF_WEIGHT_MAX",
str(64 * 1024 * 1024 * 1024)))
if max_bytes <= 0:
raise RuntimeError(
"COMFY_SECURE_HF_WEIGHT_MAX must be positive")
info = hf_hub_download(**request, dry_run=True)
size = getattr(info, "file_size", None)
if type(size) is not int or size <= 0:
raise RuntimeError("Hugging Face did not report a valid weight size")
if size > max_bytes:
raise ValueError(
f"Hugging Face weight is {size} bytes, over the "
f"{max_bytes} byte limit")
source = os.path.realpath(str(hf_hub_download(**request)))
if not os.path.isfile(source) or os.path.getsize(source) != size:
raise RuntimeError("Hugging Face weight download is incomplete")
parent = os.path.dirname(destination)
os.makedirs(parent, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(
prefix=".hf-weight-", suffix=extension, dir=parent)
os.close(descriptor)
try:
shutil.copyfile(source, temporary)
cls._verify_weight_file(temporary, extension)
if sha256 is not None and cls._file_sha256(temporary) != sha256:
raise ValueError("Hugging Face weight sha256 does not match")
os.chmod(temporary, 0o644)
os.replace(temporary, destination)
cls._HF_VERIFIED_WEIGHTS[destination] = (
*cls._weight_identity(destination), sha256)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
return logical
async def download_huggingface_weights(
self, repo_id: str, filename: str, folder: str,
revision: str = "main", sha256: Optional[str] = None,
) -> str:
"""Install one public Hugging Face weight file.
SafeTensors/SFT, GGUF, and standard self-contained ONNX graphs are
parsed structurally. PyTorch archives are
admitted only when the restricted ``weights_only`` unpickler proves
that the object graph is a tensor container. No URL, endpoint, token,
destination path, or custom/external ONNX graph is accepted. ONNX
downloads additionally require a SHA-256 pin. A valid installed file
is reused without a network request. The returned value is a logical
catalogue name, never a path.
"""
return await asyncio.to_thread(
self._download_huggingface_weights,
repo_id, filename, folder, revision, sha256)
@staticmethod
def _model_name(name: str, field: str = "name") -> str:
if not isinstance(name, str):
raise TypeError(f"model {field} must be a string")
logical = name.replace("\\", "/")
if (not logical or "\x00" in logical or logical.startswith("/")
or (len(logical) > 1 and logical[1] == ":")
or any(part == ".." for part in logical.split("/"))):
raise ValueError(f"model {field} must be a confined catalogue name")
return logical
@classmethod
def _load_options(
cls, weight_dtype: str, compute_dtype: str, cublas_linear: bool,
) -> tuple[dict, Any]:
import torch
if weight_dtype not in cls._WEIGHT_DTYPES:
raise ValueError(
f"unknown model weight dtype {weight_dtype!r}; choose "
f"{sorted(cls._WEIGHT_DTYPES)}")
if compute_dtype not in cls._COMPUTE_DTYPES:
raise ValueError(
f"unknown model compute dtype {compute_dtype!r}; choose "
f"{sorted(cls._COMPUTE_DTYPES)}")
if not isinstance(cublas_linear, bool):
raise TypeError("cublas_linear must be a bool")
dtypes = {
"fp8_e4m3fn": torch.float8_e4m3fn,
"fp8_e5m2": torch.float8_e5m2,
"fp16": torch.float16,
"bf16": torch.bfloat16,
"fp32": torch.float32,
}
options = {}
if weight_dtype == "fp8_e4m3fn_fast":
options.update(dtype=torch.float8_e4m3fn, fp8_optimizations=True)
elif weight_dtype != "default":
options["dtype"] = dtypes[weight_dtype]
if cublas_linear:
options["cublas_ops"] = True
compute = None if compute_dtype == "default" else dtypes[compute_dtype]
return options, compute
@staticmethod
async def _ref(kind: str, ref_type: type[_TypedRef], value: Any):
if value is None:
return None
return ref_type._wrap(await current_runtime().refs.create(kind, value))
async def list_diffusion_models(
self, include_connectors: bool = False,
) -> list[str]:
import folder_paths
if not isinstance(include_connectors, bool):
raise TypeError("include_connectors must be a bool")
names = list(folder_paths.get_filename_list("diffusion_models"))
if include_connectors:
names.extend(
name for name in folder_paths.get_filename_list("text_encoders")
if isinstance(name, str) and "connector" in name.lower())
result = []
seen = set()
for name in names:
try:
logical = self._model_name(name)
except (TypeError, ValueError):
continue
if logical not in seen:
seen.add(logical)
result.append(logical)
return result
async def load_checkpoint(
self, name: str, weight_dtype: str = "default",
compute_dtype: str = "default", cublas_linear: bool = False,
) -> tuple[ModelRef, Optional[ClipRef], Optional[VaeRef]]:
import folder_paths
import comfy.sd
logical = self._model_name(name)
options, compute = self._load_options(
weight_dtype, compute_dtype, cublas_linear)
path = folder_paths.get_full_path_or_raise("checkpoints", logical)
model, clip, vae, _ = comfy.sd.load_checkpoint_guess_config(
path, output_vae=True, output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
model_options=options)
if compute is not None:
model.set_model_compute_dtype(compute)
model.force_cast_weights = False
return (
await self._ref("MODEL", ModelRef, model),
await self._ref("CLIP", ClipRef, clip),
await self._ref("VAE", VaeRef, vae),
)
async def load_upscale_model(self, name: str) -> UpscaleModelRef:
import folder_paths
import comfy.utils
from spandrel import ImageModelDescriptor, ModelLoader
logical = self._model_name(name)
path = folder_paths.get_full_path_or_raise("upscale_models", logical)
state = comfy.utils.load_torch_file(path, safe_load=True)
if "module.layers.0.residual_group.blocks.0.norm1.weight" in state:
state = comfy.utils.state_dict_prefix_replace(
state, {"module.": ""})
model = ModelLoader().load_from_state_dict(state).eval()
if not isinstance(model, ImageModelDescriptor):
raise ValueError("upscale model must be a single-image model")
return await self._ref("UPSCALE_MODEL", UpscaleModelRef, model)
async def load_diffusion_model(
self, name: str, extra_name: Optional[str] = None,
weight_dtype: str = "default", compute_dtype: str = "default",
cublas_linear: bool = False,
) -> ModelRef:
import folder_paths
logical = self._model_name(name)
options, compute = self._load_options(
weight_dtype, compute_dtype, cublas_linear)
path = folder_paths.get_full_path_or_raise(
"diffusion_models", logical)
extra_path = None
if extra_name is not None:
extra_logical = self._model_name(extra_name, "extra_name")
extra_folder = (
"text_encoders"
if "connector" in extra_logical.lower()
else "diffusion_models")
extra_path = folder_paths.get_full_path_or_raise(
extra_folder, extra_logical)
model = _load_sdk_diffusion_model(path, options, extra_path)
if compute is not None:
model.set_model_compute_dtype(compute)
model.force_cast_weights = False
return await self._ref("MODEL", ModelRef, model)
async def load_gguf_model(
self, name: str, extra_name: Optional[str] = None,
dequant_dtype: str = "default", patch_dtype: str = "default",
patch_on_device: bool = False,
) -> ModelRef:
import torch
import folder_paths
import comfy.model_detection
import comfy.sd
import comfy.utils
logical = self._model_name(name)
extra_logical = (
None if extra_name in (None, "none")
else self._model_name(extra_name, "extra_name"))
dtypes = {
"default": None,
"target": "target",
"float32": torch.float32,
"float16": torch.float16,
"bfloat16": torch.bfloat16,
}
if dequant_dtype not in dtypes:
raise ValueError(f"unknown GGUF dequant dtype {dequant_dtype!r}")
if patch_dtype not in dtypes:
raise ValueError(f"unknown GGUF patch dtype {patch_dtype!r}")
if type(patch_on_device) is not bool:
raise TypeError("patch_on_device must be a bool")
gguf = _fixed_gguf_node_module()
try:
gguf_names = list(folder_paths.get_filename_list("unet_gguf"))
except KeyError as exc:
raise RuntimeError(
"the installed ComfyUI-GGUF extension did not register its "
"model catalogue") from exc
if logical not in gguf_names:
raise ValueError(f"unknown GGUF model catalogue name {logical!r}")
ops = gguf.GGMLOps()
ops.Linear = type("SDKGGUFLinear", (ops.Linear,), {})
ops.Linear.dequant_dtype = dtypes[dequant_dtype]
ops.Linear.patch_dtype = dtypes[patch_dtype]
model_path = folder_paths.get_full_path_or_raise(
"unet_gguf", logical)
try:
state_dict, extra = gguf.gguf_sd_loader(model_path)
except TypeError:
state_dict = gguf.gguf_sd_loader(model_path)
extra = {}
if extra_logical is not None:
if extra_logical.endswith(".gguf"):
if extra_logical not in gguf_names:
raise ValueError(
f"unknown extra GGUF catalogue name {extra_logical!r}")
extra_path = folder_paths.get_full_path_or_raise(
"unet_gguf", extra_logical)
try:
extra_state, _ = gguf.gguf_sd_loader(extra_path)
except TypeError:
extra_state = gguf.gguf_sd_loader(extra_path)
elif "connector" in extra_logical.lower():
connectors = [
value for value in folder_paths.get_filename_list(
"text_encoders")
if isinstance(value, str)
and "connector" in value.lower()
]
if extra_logical not in connectors:
raise ValueError(
f"unknown connector catalogue name {extra_logical!r}")
extra_path = folder_paths.get_full_path_or_raise(
"text_encoders", extra_logical)
extra_state = comfy.utils.load_torch_file(extra_path)
prefix = comfy.model_detection.unet_prefix_from_state_dict(
extra_state)
if prefix == "model.diffusion_model.":
stripped = comfy.utils.state_dict_prefix_replace(
extra_state, {prefix: ""}, filter_keys=True)
if stripped:
extra_state = stripped
else:
raise ValueError(
"extra GGUF model must be a catalogued .gguf file or "
"connector")
state_dict.update(extra_state)
model = comfy.sd.load_diffusion_model_state_dict(
state_dict,
model_options={"custom_operations": ops},
metadata=extra.get("metadata", {}),
)
if model is None:
raise RuntimeError(
f"could not detect GGUF model type for {logical!r}")
model = gguf.GGUFModelPatcher.clone(model)
model.patch_on_device = patch_on_device
return await self._ref("MODEL", ModelRef, model)
async def load_gguf_text_encoders(
self, names: Sequence[str], clip_type: str,
) -> ClipRef:
"""Load one to four GGUF-quantized text encoders as a single CLIP.
The text-encoder counterpart of ``load_gguf_model``. GGUF is a
quantization container, not a vendor format, and loading a quantized
text encoder is the same basic host operation as loading a quantized
diffusion model — so this is a primitive, not pack support. The pack
keeps its own catalogue presentation and node shapes; the host owns
file resolution, the custom operations, and the patcher.
``names`` may mix ``.gguf`` files and ordinary state dicts, which is
what the two-, three- and four-encoder loaders exist for.
"""
import comfy.model_management
import comfy.sd
import comfy.utils
import folder_paths
if isinstance(names, str) or not isinstance(names, (list, tuple)):
raise TypeError("names must be a sequence of catalogue names")
# One per loader variant: CLIP, Dual, Triple, Quadruple. An unbounded
# list would let one request pin arbitrarily many encoders.
if not 1 <= len(names) <= 4:
raise ValueError(
f"expected 1 to 4 text encoders, got {len(names)}")
# Strict, unlike core's in-process CLIPLoader, which falls back to
# STABLE_DIFFUSION for an unknown type string. In-process that is a
# convenience; for a guest-facing primitive it would silently build a
# different model than the caller asked for.
if not isinstance(clip_type, str) or not clip_type:
raise TypeError("clip_type must be a string")
resolved_type = getattr(
comfy.sd.CLIPType, clip_type.upper().replace("-", "_"), None)
if resolved_type is None:
raise ValueError(f"unknown CLIP type {clip_type!r}")
catalogue = set(folder_paths.get_filename_list("text_encoders"))
for folder in ("clip", "clip_gguf"):
try:
catalogue.update(folder_paths.get_filename_list(folder))
except KeyError:
continue
state_dicts = []
for index, name in enumerate(names):
logical = self._model_name(name, f"names[{index}]")
if logical not in catalogue:
raise ValueError(
f"unknown text encoder catalogue name {logical!r}")
path = folder_paths.get_full_path_or_raise(
"clip_gguf" if logical.endswith(".gguf") else "text_encoders",
logical)
if logical.endswith(".gguf"):
gguf = _fixed_gguf_node_module()
if not hasattr(gguf, "gguf_clip_loader"):
raise RuntimeError(
"the installed ComfyUI-GGUF extension is "
"incompatible: missing gguf_clip_loader")
state_dicts.append(gguf.gguf_clip_loader(path))
else:
state = comfy.utils.load_torch_file(path, safe_load=True)
if "scaled_fp8" in state:
# Upstream's own guard: scaled FP8 needs different custom
# operations and only one set can be active.
raise ValueError(
f"{logical!r} is scaled FP8, which cannot be mixed "
"with GGUF text encoders")
state_dicts.append(state)
gguf = _fixed_gguf_node_module()
clip = comfy.sd.load_text_encoder_state_dicts(
clip_type=resolved_type,
state_dicts=state_dicts,
model_options={
"custom_operations": gguf.GGMLOps,
"initial_device":
comfy.model_management.text_encoder_offload_device(),
},
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
clip.patcher = gguf.GGUFModelPatcher.clone(clip.patcher)
return await self._ref("CLIP", ClipRef, clip)
async def list_controlnet(self) -> list[str]:
import folder_paths
result = []
seen = set()
for name in folder_paths.get_filename_list("controlnet"):
try:
logical = self._model_name(name)
except (TypeError, ValueError):
continue
if logical not in seen:
seen.add(logical)
result.append(logical)
return result
async def load_controlnet(
self, name: str, model: Optional[ModelRef] = None,
) -> ControlNetRef:
import comfy.controlnet
import folder_paths
logical = self._model_name(name)
path = folder_paths.get_full_path_or_raise("controlnet", logical)
model_value = (
None if model is None
else await current_runtime().refs.resolve(model))
control_net = comfy.controlnet.load_controlnet(path, model_value)
if control_net is None:
raise RuntimeError(
"the selected file does not contain a valid ControlNet model")
return ControlNetRef._wrap(await current_runtime().refs.create(
"CONTROL_NET", control_net)) # type: ignore[return-value]
async def load_advanced_controlnet(
self, name: str, model: Optional[ModelRef] = None,
timestep_keyframe: Optional[TimestepKeyframeRef] = None,
) -> ControlNetRef:
import folder_paths
logical = self._model_name(name)
path = folder_paths.get_full_path_or_raise("controlnet", logical)
rt = current_runtime()
model_value = None if model is None else await rt.refs.resolve(model)
keyframe_value = (
None if timestep_keyframe is None
else await rt.refs.resolve(timestep_keyframe))
control = _advanced_control_module("control")
control_net = control.load_controlnet(
path, keyframe_value, model_value)
if control_net is None:
raise RuntimeError(
"the selected file does not contain a valid ControlNet model")
if control.is_advanced_controlnet(control_net):
control_net.verify_all_weights()
return ControlNetRef._wrap(await rt.refs.create(
"CONTROL_NET", control_net)) # type: ignore[return-value]
async def load_controlnet_plusplus(
self, name: str, control_type: str = "none",
) -> ControlNetRef:
import folder_paths
choices = {
"openpose", "depth", "hed/pidi/scribble/ted",
"canny/lineart/mlsd", "normal", "segment", "tile",
"inpaint/outpaint", "none",
}
if control_type not in choices:
raise ValueError(
f"unknown ControlNet++ control type {control_type!r}")
logical = self._model_name(name)
path = folder_paths.get_full_path_or_raise("controlnet", logical)
plusplus = _advanced_control_module("control_plusplus")
control_net = plusplus.load_controlnetplusplus(path)
control_net.single_control_type = control_type
control_net.verify_control_type(logical)
return ControlNetRef._wrap(await current_runtime().refs.create(
"CONTROL_NET", control_net)) # type: ignore[return-value]
async def list_vae(self) -> list[str]:
import nodes
return list(nodes.VAELoader.vae_list(nodes.VAELoader))
async def load_vae(
self, name: str, device: str = "default",
weight_dtype: str = "default",
) -> VaeRef:
import torch
import comfy.model_management
import comfy.sd
import nodes
devices = {
"default": None,
"main_device": comfy.model_management.get_torch_device(),
"cpu": torch.device("cpu"),
}
dtypes = {
"default": None,
"bf16": torch.bfloat16,
"fp16": torch.float16,
"fp32": torch.float32,
}
if device not in devices:
raise ValueError(
f"unknown VAE device {device!r}; choose default, main_device, or cpu")
if weight_dtype not in dtypes:
raise ValueError(
f"unknown VAE dtype {weight_dtype!r}; choose default, bf16, fp16, or fp32")
sd, metadata, vae_path = nodes.VAELoader.load_vae_state_dict(str(name))
audio_keys = {
"vocoder.conv_post.weight",
"vocoder.vocoder.conv_post.weight",
"vocoder.resblocks.0.convs1.0.weight",
"vocoder.vocoder.resblocks.0.convs1.0.weight",
}
if audio_keys.intersection(sd):
from comfy.utils import state_dict_prefix_replace
audio_sd = state_dict_prefix_replace(
dict(sd), {
"audio_vae.": "autoencoder.",
"vocoder.": "vocoder.",
}, filter_keys=True)
vae = comfy.sd.VAE(sd=audio_sd, metadata=metadata)
else:
vae = comfy.sd.VAE(
sd=sd, metadata=metadata, device=devices[device],
dtype=dtypes[weight_dtype])
vae.throw_exception_if_invalid()
if vae_path is not None and weight_dtype == "default":
vae.patcher.cached_patcher_init = (
comfy.sd.load_vae_patcher,
(vae_path, metadata, devices[device]))
return VaeRef._wrap(await current_runtime().refs.create("VAE", vae)) # type: ignore[return-value]
async def load_clip_vision(self, model: str) -> ClipVisionRef:
import comfy.clip_vision
import folder_paths
model = self._model_name(model, "CLIP-Vision weight")
if not model.lower().endswith((".safetensors", ".sft")):
raise ValueError("CLIP-Vision weights must use SafeTensors")
path = folder_paths.get_full_path_or_raise("clip_vision", model)
value = await asyncio.to_thread(comfy.clip_vision.load, path)
if value is None:
raise ValueError("the selected weight is not a CLIP-Vision model")
return ClipVisionRef._wrap(await current_runtime().refs.create(
"CLIP_VISION", value)) # type: ignore[return-value]
async def load_text_encoder(
self, model: str, model_type: str,
device: str = "default",
) -> ClipRef:
import comfy.sd
import folder_paths
model = self._model_name(model, "text-encoder weight")
if not model.lower().endswith((".safetensors", ".sft")):
raise ValueError("text-encoder weights must use SafeTensors")
model_type = str(model_type).replace("-", "_").lower()
if model_type.upper() not in comfy.sd.CLIPType.__members__:
raise ValueError(
f"unknown Comfy text-encoder type {model_type!r}")
device = str(device)
if device not in {"default", "cpu"}:
raise ValueError("text-encoder device must be default or cpu")
path = folder_paths.get_full_path_or_raise("text_encoders", model)
entry = await asyncio.to_thread(
_TEXT_ENCODER_CACHE.get, path, model_type, device)
setattr(entry.clip, "_secure_text_generation_lock", entry.lock)
return ClipRef._wrap(await current_runtime().refs.create(
"CLIP", entry.clip)) # type: ignore[return-value]
async def load_language_model(
self, weights: list[str], family: str,
device: str = "default", cache: bool = True,
) -> ClipRef:
import folder_paths
if not isinstance(weights, (list, tuple)):
raise TypeError("language-model weights must be a list")
if not 1 <= len(weights) <= 16:
raise ValueError("language models require 1 to 16 weight shards")
logical_weights = []
seen = set()
for item in weights:
logical = self._model_name(item, "language-model weight")
if not logical.lower().endswith((".safetensors", ".sft")):
raise ValueError(
"language-model weights must use SafeTensors")
if logical in seen:
raise ValueError("language-model weight shards must be unique")
seen.add(logical)
logical_weights.append(logical)
family = str(family).lower()
if family not in _QWEN_LANGUAGE_FAMILIES:
raise ValueError(f"unsupported language-model family {family!r}")
device = str(device)
if device not in {"default", "cpu"}:
raise ValueError("language-model device must be default or cpu")
if type(cache) is not bool:
raise TypeError("language-model cache must be a boolean")
paths = tuple(
folder_paths.get_full_path_or_raise("text_encoders", logical)
for logical in logical_weights
)
entry = await asyncio.to_thread(
_LANGUAGE_MODEL_CACHE.get, paths, family, device, cache)
setattr(entry.clip, "_secure_text_generation_lock", entry.lock)
setattr(entry.clip, "_secure_language_family", family)
return ClipRef._wrap(await current_runtime().refs.create(
"CLIP", entry.clip)) # type: ignore[return-value]
async def load_ipadapter(
self, model: str, clip_vision: ClipVisionRef,
) -> IpAdapterRef:
import folder_paths
import nodes
model = self._model_name(model, "IP-Adapter weight")
if not model.lower().endswith((".safetensors", ".sft")):
raise ValueError("IP-Adapter weights must use SafeTensors")
folder_paths.get_full_path_or_raise("ipadapter", model)
node_class = getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get(
"IPAdapterModelLoader")
if node_class is None:
raise RuntimeError(
"IP-Adapter loading requires the host-installed "
"ComfyUI IPAdapter Plus extension")
result = await asyncio.to_thread(
node_class().load_ipadapter_model, model)
if (not isinstance(result, (tuple, list)) or not result
or not isinstance(result[0], dict)
or not isinstance(result[0].get("ip_adapter"), dict)
or not result[0]["ip_adapter"]):
raise ValueError("the selected weight is not an IP-Adapter model")
vision = await current_runtime().refs.resolve(clip_vision)
value = {
"secure_kind": "ipadapter.pipeline",
"ipadapter": result[0],
"clip_vision": vision,
}
return IpAdapterRef._wrap(await current_runtime().refs.create(
"IPADAPTER_PIPE", value)) # type: ignore[return-value]
async def load_brushnet(
self, model: str, dtype: str = "float16",
) -> BrushNetRef:
import folder_paths
import nodes
model = self._model_name(model, "BrushNet weight")
if not model.lower().endswith((".safetensors", ".sft")):
raise ValueError("BrushNet weights must use SafeTensors")
if dtype not in {"float16", "bfloat16", "float32", "float64"}:
raise ValueError(
"BrushNet dtype must be float16, bfloat16, float32, or float64")
folder_paths.get_full_path_or_raise("inpaint", model)
node_class = getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get(
"BrushNetLoader")
if node_class is None:
raise RuntimeError(
"BrushNet loading requires the host-installed canonical "
"ComfyUI-BrushNet extension")
result = await asyncio.to_thread(
node_class().brushnet_loading, model, dtype)
if (not isinstance(result, (tuple, list)) or len(result) != 1
or not isinstance(result[0], dict)
or result[0].get("brushnet") is None
or not isinstance(result[0].get("SDXL"), bool)
or not isinstance(result[0].get("PP"), bool)
or result[0].get("dtype") is None):
raise ValueError(
"the canonical BrushNet loader returned an invalid model")
if result[0]["PP"]:
raise ValueError(
"the selected weight is PowerPaint, not a BrushNet model")
return BrushNetRef._wrap(await current_runtime().refs.create(
"BRUSHNET_MODEL", result[0])) # type: ignore[return-value]
async def load_powerpaint(
self, model: str, base_clip: str, powerpaint_clip: str,
dtype: str = "float16",
) -> PowerPaintRef:
import folder_paths
import nodes
model = self._model_name(model, "PowerPaint weight")
base_clip = self._model_name(base_clip, "base CLIP weight")
powerpaint_clip = self._model_name(
powerpaint_clip, "PowerPaint CLIP weight")
for label, value in (
("PowerPaint", model),
("base CLIP", base_clip),
("PowerPaint CLIP", powerpaint_clip),
):
if not value.lower().endswith((".safetensors", ".sft")):
raise ValueError(f"{label} weights must use SafeTensors")
if dtype not in {"float16", "bfloat16", "float32", "float64"}:
raise ValueError(
"PowerPaint dtype must be float16, bfloat16, float32, or "
"float64")
model_path = folder_paths.get_full_path_or_raise("inpaint", model)
base_path = folder_paths.get_full_path_or_raise(
"text_encoders", base_clip)
clip_path = folder_paths.get_full_path_or_raise(
"inpaint", powerpaint_clip)
mappings = getattr(nodes, "NODE_CLASS_MAPPINGS", {})
brushnet_class = mappings.get("BrushNetLoader")
clip_class = mappings.get("PowerPaintCLIPLoader")
if brushnet_class is None or clip_class is None:
raise RuntimeError(
"PowerPaint loading requires the host-installed canonical "
"ComfyUI-BrushNet extension")
def load():
brushnet_loader = brushnet_class()
model_key = os.path.basename(model_path)
brushnet_loader.inpaint_files = {
model_key: os.path.dirname(model_path)}
model_result = brushnet_loader.brushnet_loading(model_key, dtype)
clip_loader = clip_class()
base_key = os.path.basename(base_path)
clip_key = os.path.basename(clip_path)
clip_loader.clip_files = {base_key: os.path.dirname(base_path)}
clip_loader.inpaint_files = {clip_key: os.path.dirname(clip_path)}
clip_result = clip_loader.ppclip_loading(base_key, clip_key)
return model_result, clip_result
model_result, clip_result = await asyncio.to_thread(load)
if (not isinstance(model_result, (tuple, list))
or len(model_result) != 1
or not isinstance(model_result[0], dict)
or model_result[0].get("brushnet") is None
or model_result[0].get("PP") is not True
or model_result[0].get("dtype") is None):
raise ValueError(
"the canonical loader did not return a PowerPaint model")
if (not isinstance(clip_result, (tuple, list))
or len(clip_result) != 1 or clip_result[0] is None):
raise ValueError(
"the canonical loader did not return a PowerPaint CLIP")
value = {
"secure_kind": "powerpaint.pipeline",
"powerpaint": model_result[0],
"clip": clip_result[0],
}
return PowerPaintRef._wrap(await current_runtime().refs.create(
"POWERPAINT_MODEL", value)) # type: ignore[return-value]
async def load_transparent_vae_decoder(
self, model: str, family: str,
) -> TransparentVaeDecoderRef:
import folder_paths
model = self._model_name(model, "transparent VAE decoder weight")
if not model.lower().endswith((".safetensors", ".sft")):
raise ValueError(
"transparent VAE decoder weights must use SafeTensors")
family = str(family)
expected = {
"sd1": "layer_sd15_vae_transparent_decoder.safetensors",
"sdxl": "vae_transparent_decoder.safetensors",
}
if family not in expected:
raise ValueError(
"transparent VAE decoder family must be sd1 or sdxl")
if os.path.basename(model).lower() != expected[family].lower():
raise ValueError(
f"{family} transparent VAE decoding requires "
f"{expected[family]!r}")
path = folder_paths.get_full_path_or_raise("vae", model)
entry = await asyncio.to_thread(
_TRANSPARENT_VAE_DECODER_CACHE.get, path, family)
return TransparentVaeDecoderRef._wrap(
await current_runtime().refs.create(
"TRANSPARENT_VAE_DECODER", entry)
) # type: ignore[return-value]
async def memory_cleanup(
self, empty_cache: bool = True, collect_cycles: bool = True,
unload_all_models: bool = False,
) -> tuple[int, int]:
import gc
import comfy.model_management
before = int(comfy.model_management.get_free_memory())
if bool(empty_cache):
comfy.model_management.soft_empty_cache()
if bool(unload_all_models):
comfy.model_management.unload_all_models()
_TEXT_GENERATOR_CACHE.clear()
_INPAINT_MODEL_CACHE.clear()
_CLIPSEG_CACHE.clear()
_IMAGE_CLASSIFIER_CACHE.clear()
_ONNX_IMAGE_CLASSIFIER_CACHE.clear()
_TEXT_ENCODER_CACHE.clear()
_LANGUAGE_MODEL_CACHE.clear()
InProcessLlamaCpp().clear()
_SEGFORMER_CACHE.clear()
_VITMATTE_CACHE.clear()
_VQA_CACHE.clear()
_SAM_CACHE.clear()
_TRANSPARENT_VAE_DECODER_CACHE.clear()
if bool(collect_cycles):
gc.collect()
after = int(comfy.model_management.get_free_memory())
return before, after
async def load_clipseg(self, model: str) -> ClipSegRef:
import folder_paths
model = self._model_name(model, "CLIPSeg weight")
if not model.lower().endswith(".safetensors"):
raise ValueError("CLIPSeg weights must use SafeTensors")
path = folder_paths.get_full_path_or_raise("detection", model)
value = (await asyncio.to_thread(_CLIPSEG_CACHE.get, path)).bundle()
return ClipSegRef._wrap(await current_runtime().refs.create(
"CLIPSEGMODEL", value)) # type: ignore[return-value]
async def load_image_classifier(
self, model: str, architecture: str, labels: list[str],
) -> ImageClassifierRef:
import folder_paths
model = self._model_name(model, "image classifier weight")
if not model.lower().endswith(".safetensors"):
raise ValueError("image classifier weights must use SafeTensors")
architecture = str(architecture)
if architecture not in {
"vit-base-patch16-224",
"beit-base-patch16-224",
"resnet-50-224",
}:
raise ValueError("image classifier architecture is not supported")
if not isinstance(labels, (list, tuple)):
raise TypeError("image classifier labels must be a list")
labels = tuple(str(label) for label in labels)
if (not labels or len(labels) > 10_000
or any(not label or len(label) > 256 for label in labels)):
raise ValueError("image classifier labels are invalid")
path = folder_paths.get_full_path_or_raise("detection", model)
entry = await asyncio.to_thread(
_IMAGE_CLASSIFIER_CACHE.get, path, architecture)
if len(labels) != entry.num_labels:
raise ValueError(
"image classifier labels do not match the weight output count")
value = {
"model": entry.model,
"processor": entry.processor,
"architecture": entry.architecture,
"labels": labels,
"lock": entry.lock,
}
return ImageClassifierRef._wrap(await current_runtime().refs.create(
"IMAGE_CLASSIFIER", value)) # type: ignore[return-value]
async def load_onnx_image_classifier(
self, model: str, input_layout: str = "NHWC",
channel_order: str = "BGR", resize_mode: str = "fit_pad",
input_scale: float = 255.0,
pad_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
mean: tuple[float, float, float] = (0.0, 0.0, 0.0),
std: tuple[float, float, float] = (1.0, 1.0, 1.0),
activation: str = "identity", resize_filter: str = "lanczos",
) -> ImageClassifierRef:
"""Bind a self-contained standard ONNX image classifier.
Preprocessing is a closed, reusable transform. Labels, category
ranges, thresholds, exclusions, and output formatting remain node
code; the host only retains and pages the numeric score matrix.
"""
import math
import folder_paths
model = self._model_name(model, "ONNX image classifier")
if not model.lower().endswith(".onnx"):
raise ValueError("ONNX image classifiers must use .onnx files")
input_layout = str(input_layout).upper()
channel_order = str(channel_order).upper()
resize_mode = str(resize_mode).lower()
activation = str(activation).lower()
resize_filter = str(resize_filter).lower()
if input_layout not in {"NHWC", "NCHW"}:
raise ValueError("ONNX classifier layout must be NHWC or NCHW")
if channel_order not in {"RGB", "BGR"}:
raise ValueError("ONNX classifier channel order must be RGB or BGR")
if resize_mode not in {"fit_pad", "stretch"}:
raise ValueError("ONNX classifier resize mode is not supported")
if activation not in {"identity", "sigmoid", "softmax"}:
raise ValueError("ONNX classifier activation is not supported")
if resize_filter not in {"nearest", "bilinear", "bicubic", "lanczos"}:
raise ValueError("ONNX classifier resize filter is not supported")
input_scale = float(input_scale)
if not math.isfinite(input_scale) or not 0 < input_scale <= 65_535:
raise ValueError("ONNX classifier input scale is invalid")
def triple(
value: Any, field_name: str, *, nonzero: bool = False,
unit: bool = False,
) -> tuple[float, float, float]:
if not isinstance(value, (list, tuple)) or len(value) != 3:
raise ValueError(
f"ONNX classifier {field_name} must have three values")
result = tuple(float(item) for item in value)
if (any(not math.isfinite(item) or abs(item) > 1_000_000
for item in result)
or (nonzero and any(item == 0 for item in result))
or (unit and any(not 0 <= item <= 1 for item in result))):
raise ValueError(f"ONNX classifier {field_name} is invalid")
return result # type: ignore[return-value]
pad_color = triple(pad_color, "pad color", unit=True)
mean = triple(mean, "mean")
std = triple(std, "standard deviation", nonzero=True)
path = folder_paths.get_full_path_or_raise("onnx", model)
entry = await asyncio.to_thread(_ONNX_IMAGE_CLASSIFIER_CACHE.get, path)
if input_layout not in entry.input_layouts:
raise ValueError(
f"ONNX classifier tensor is not laid out as {input_layout}")
value = {
"secure_kind": "image_classifier.onnx",
"session": entry.session,
"input_name": entry.input_name,
"output_name": entry.output_name,
"input_height": entry.input_height,
"input_width": entry.input_width,
"class_count": entry.class_count,
"input_layout": input_layout,
"channel_order": channel_order,
"resize_mode": resize_mode,
"input_scale": input_scale,
"pad_color": pad_color,
"mean": mean,
"std": std,
"activation": activation,
"resize_filter": resize_filter,
"lock": entry.lock,
}
return ImageClassifierRef._wrap(await current_runtime().refs.create(
"IMAGE_CLASSIFIER", value)) # type: ignore[return-value]
async def load_segformer(
self, model: str, variant: str, num_labels: int,
) -> SemanticSegmentationRef:
import folder_paths
model = self._model_name(model, "SegFormer weight")
if not model.lower().endswith((".safetensors", ".sft")):
raise ValueError("SegFormer weights must use SafeTensors")
variant = str(variant)
if variant not in {"b2", "b3", "b5"}:
raise ValueError("SegFormer variant must be b2, b3, or b5")
if (isinstance(num_labels, bool) or not isinstance(num_labels, int)
or not 1 <= num_labels <= 1024):
raise ValueError("SegFormer num_labels must be in [1, 1024]")
path = folder_paths.get_full_path_or_raise(
"semantic_segmentation", model)
entry = await asyncio.to_thread(
_SEGFORMER_CACHE.get, path, variant, num_labels)
return SemanticSegmentationRef._wrap(
await current_runtime().refs.create(
"SEMANTIC_SEGMENTATION_MODEL", entry)
) # type: ignore[return-value]
async def load_vitmatte(
self, model: str, variant: str,
) -> MattingModelRef:
import folder_paths
model = self._model_name(model, "ViTMatte weight")
if not model.lower().endswith((".safetensors", ".sft", ".bin")):
raise ValueError("ViTMatte weights must be SafeTensors or a weight-only bin")
variant = str(variant)
if variant not in {"small", "base"}:
raise ValueError("ViTMatte variant must be small or base")
path = folder_paths.get_full_path_or_raise("detection", model)
entry = await asyncio.to_thread(
_VITMATTE_CACHE.get, path, variant)
return MattingModelRef._wrap(await current_runtime().refs.create(
"MATTING_MODEL", entry)) # type: ignore[return-value]
async def load_vqa(
self, model: str, architecture: str,
precision: str = "fp16", device: str = "cuda",
) -> VqaModelRef:
import folder_paths
model = self._model_name(model, "BLIP VQA weight")
if not model.lower().endswith((".safetensors", ".sft", ".bin")):
raise ValueError("BLIP VQA weights must be SafeTensors or a weight-only bin")
architecture = str(architecture)
if architecture not in {
"blip-vqa-base", "blip-vqa-capfilt-large",
}:
raise ValueError("unknown BLIP VQA architecture")
precision = str(precision)
if precision not in {"fp16", "fp32"}:
raise ValueError("BLIP VQA precision must be fp16 or fp32")
device = str(device)
if device not in {"cuda", "cpu"}:
raise ValueError("BLIP VQA device must be cuda or cpu")
path = folder_paths.get_full_path_or_raise("detection", model)
entry = await asyncio.to_thread(
_VQA_CACHE.get, path, architecture)
value = _VqaModelValue(
entry=entry, precision=precision, device=device)
return VqaModelRef._wrap(await current_runtime().refs.create(
"VQA_MODEL", value)) # type: ignore[return-value]
async def load_inpaint_model(
self, model: str, architecture: str = "big-lama",
) -> InpaintModelRef:
import folder_paths
model = self._model_name(model, "image inpaint weight")
if not model.lower().endswith((".safetensors", ".sft")):
raise ValueError("image inpaint weights must use SafeTensors")
architecture = str(architecture)
if architecture != "big-lama":
raise ValueError("unknown image inpaint architecture")
path = folder_paths.get_full_path_or_raise("detection", model)
entry = await asyncio.to_thread(
_INPAINT_MODEL_CACHE.get, path, architecture)
return InpaintModelRef._wrap(await current_runtime().refs.create(
"INPAINT_MODEL", entry.bundle())) # type: ignore[return-value]
async def load_background_removal_model(
self, model: str,
) -> BackgroundRemovalModelRef:
import folder_paths
from comfy.bg_removal_model import load
model = self._model_name(model, "background-removal weight")
if not model.lower().endswith((".safetensors", ".sft")):
raise ValueError(
"background-removal weights must use SafeTensors")
path = folder_paths.get_full_path_or_raise(
"background_removal", model)
value = await asyncio.to_thread(load, path)
if value is None:
raise ValueError(
"the selected weight is not a supported ComfyUI "
"background-removal model")
bundle = {
"secure_kind": "background_removal.comfy",
"model": value,
"lock": threading.Lock(),
}
return BackgroundRemovalModelRef._wrap(
await current_runtime().refs.create(
"BACKGROUND_REMOVAL_MODEL", bundle)
) # type: ignore[return-value]
async def load_onnx_detector(self, model: str) -> OnnxDetectorRef:
import folder_paths
model = self._model_name(model, "ONNX detector")
if not model.lower().endswith(".onnx"):
raise ValueError("ONNX detectors must use .onnx model files")
path = folder_paths.get_full_path_or_raise("onnx", model)
entry = await asyncio.to_thread(_ONNX_DETECTOR_CACHE.get, path)
value = {
"secure_kind": "onnx.object_detector",
"model": entry.model,
"lock": entry.lock,
}
return OnnxDetectorRef._wrap(await current_runtime().refs.create(
"ONNX_DETECTOR", value)) # type: ignore[return-value]
async def load_object_detector(self, model: str) -> ObjectDetectorRef:
import comfy.model_base
model = self._model_name(model, "object detector")
if not model.lower().endswith((".safetensors", ".sft")):
raise ValueError("object-detector weights must use SafeTensors")
model_ref = await self.load_diffusion_model(model)
patcher = await current_runtime().refs.resolve(model_ref)
if not isinstance(patcher.model, comfy.model_base.RT_DETR_v4):
raise ValueError(
"the selected weight is not a supported RT-DETR model")
value = {
"secure_kind": "object_detector.rt_detr",
"model": patcher,
}
return ObjectDetectorRef._wrap(await current_runtime().refs.create(
"OBJECT_DETECTOR", value)) # type: ignore[return-value]
async def load_sam(
self, model: str, architecture: str = "vit_b",
device_mode: str = "AUTO",
) -> SamModelRef:
import folder_paths
model = self._model_name(model, "SAM weight")
if not model.lower().endswith((".safetensors", ".sft")):
raise ValueError("SAM weights must use SafeTensors")
architecture = str(architecture)
if architecture not in {"vit_b", "vit_l", "vit_h", *_SAM2_CONFIGS}:
raise ValueError("unknown SAM architecture")
device_mode = str(device_mode)
if device_mode not in {"AUTO", "Prefer GPU", "CPU"}:
raise ValueError("SAM device_mode must be AUTO, Prefer GPU, or CPU")
path = folder_paths.get_full_path_or_raise("sams", model)
is_sam2 = architecture in _SAM2_CONFIGS
cache = _SAM2_CACHE if is_sam2 else _SAM_CACHE
entry = await asyncio.to_thread(cache.get, path, architecture)
value = {
"secure_kind": "sam.v2" if is_sam2 else "sam.v1",
"model": entry.model,
"architecture": entry.architecture,
"device_mode": device_mode,
"lock": entry.lock,
}
return SamModelRef._wrap(await current_runtime().refs.create(
"SAM_MODEL", value)) # type: ignore[return-value]
async def generate_text(
self, generator: str, input_text: str, max_new_tokens: int = 128,
weight: Optional[str] = None,
) -> str:
import folder_paths
if not isinstance(generator, str):
raise TypeError("text generator must be a string")
if generator != "superprompt-v1":
raise ValueError(
f"text generator {generator!r} is not in the trusted catalogue")
if not isinstance(input_text, str):
raise TypeError("text generator input must be a string")
if len(input_text) > 32768:
raise ValueError("text generator input exceeds 32768 characters")
if type(max_new_tokens) is not int:
raise TypeError("max_new_tokens must be an int")
if not 1 <= max_new_tokens <= 4096:
raise ValueError("max_new_tokens must be in [1, 4096]")
if weight is None:
raise ValueError(
"text generator 'superprompt-v1' requires a declared weight")
weight = self._model_name(weight, "weight")
if not weight.lower().endswith(".safetensors"):
raise ValueError("text generator weights must use SafeTensors")
weight_path = folder_paths.get_full_path_or_raise(
"text_encoders", weight)
return await asyncio.to_thread(
_TEXT_GENERATOR_CACHE.generate,
generator, weight_path, input_text, max_new_tokens)
class _InProcessCapture:
async def screen(self, region=None, monitor: int = 1) -> ImageRef:
import asyncio
import mss
import numpy as np
import torch
def grab():
with mss.mss() as capture:
if region is None:
index = int(monitor)
if not 0 <= index < len(capture.monitors):
raise ValueError(
f"monitor {monitor} is outside "
f"0..{len(capture.monitors) - 1}")
target = capture.monitors[index]
else:
left, top, right, bottom = map(int, region)
width = right - left
height = bottom - top
if width <= 0 or height <= 0:
raise ValueError(
"screen region must have positive width and height")
if width * height > 67_108_864:
raise ValueError("screen region exceeds 67108864 pixels")
target = {
"left": left,
"top": top,
"width": width,
"height": height,
}
return np.asarray(
capture.grab(target))[..., :3][..., ::-1].copy()
pixels = await asyncio.to_thread(grab)
tensor = torch.from_numpy(pixels).to(torch.float32).div_(255.0).unsqueeze(0)
return ImageRef._wrap(
await current_runtime().refs.create("IMAGE", tensor)) # type: ignore[return-value]
async def camera(self, index: int = 0, width=None,
height=None) -> ImageRef:
import asyncio
import cv2
import torch
camera_index = int(index)
if camera_index < 0:
raise ValueError("camera index must be non-negative")
requested_width = None if width is None else int(width)
requested_height = None if height is None else int(height)
for name, value in (("width", requested_width),
("height", requested_height)):
if value is not None and not 1 <= value <= 16384:
raise ValueError(f"camera {name} must be in [1, 16384]")
if (requested_width is not None and requested_height is not None and
requested_width * requested_height > 67_108_864):
raise ValueError("camera frame exceeds 67108864 pixels")
def read_frame():
capture = cv2.VideoCapture(camera_index)
try:
if requested_width is not None:
capture.set(cv2.CAP_PROP_FRAME_WIDTH, requested_width)
if requested_height is not None:
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, requested_height)
ok, frame = capture.read()
if not ok or frame is None:
raise RuntimeError(
f"camera {camera_index} did not return a frame")
return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
finally:
capture.release()
frame = await asyncio.to_thread(read_frame)
tensor = torch.from_numpy(frame.copy()).to(torch.float32).div_(255.0).unsqueeze(0)
return ImageRef._wrap(
await current_runtime().refs.create("IMAGE", tensor)) # type: ignore[return-value]
async def audio(self, duration: float, sample_rate: int = 44100,
channels: int = 1) -> AudioRef:
import asyncio
import sounddevice
import torch
duration = float(duration)
sample_rate = int(sample_rate)
channels = int(channels)
if not 0.0 < duration <= 60.0:
raise ValueError("audio capture duration must be in (0, 60] seconds")
if not 8000 <= sample_rate <= 192000:
raise ValueError("audio sample rate must be in [8000, 192000]")
if not 1 <= channels <= 8:
raise ValueError("audio channels must be in [1, 8]")
def record():
data = sounddevice.rec(
round(duration * sample_rate), samplerate=sample_rate,
channels=channels, dtype="float32", blocking=True)
return data.copy()
data = await asyncio.to_thread(record)
waveform = torch.from_numpy(data.T.copy()).unsqueeze(0)
return AudioRef._wrap(await current_runtime().refs.create(
"AUDIO", {"waveform": waveform, "sample_rate": sample_rate})) # type: ignore[return-value]
def _image_metadata_owner(
prompt: Any = None,
extra_pnginfo: Any = None,
*,
include_execution_metadata: bool = True,
extra_metadata: Optional[dict[str, Any]] = None,
):
"""Build the small object expected by ComfyUI's image save helpers.
The broker owns prompt/workflow injection. Nodes may add ordinary JSON
fields, but never receive the hidden execution metadata merely to save it
again.
"""
import json
from types import SimpleNamespace
if extra_metadata is not None and not isinstance(extra_metadata, dict):
raise TypeError("extra image metadata must be a mapping")
try:
encoded = json.dumps(extra_metadata or {}, allow_nan=False)
except (TypeError, ValueError) as error:
raise ValueError("extra image metadata must be JSON-compatible") from error
if len(encoded.encode("utf-8")) > 1024 * 1024:
raise ValueError("extra image metadata exceeds 1 MiB")
normalized_extra = json.loads(encoded)
metadata = {}
if include_execution_metadata and isinstance(extra_pnginfo, dict):
metadata.update(extra_pnginfo)
metadata.update(normalized_extra)
hidden = SimpleNamespace(
prompt=prompt if include_execution_metadata else None,
extra_pnginfo=metadata or None,
)
return SimpleNamespace(hidden=hidden)
class _InProcessUi:
def __init__(self, prompt: Any = None, extra_pnginfo: Any = None) -> None:
self._metadata_owner = _image_metadata_owner(prompt, extra_pnginfo)
async def preview_images(self, images: ImageRef,
animated: bool = False) -> dict:
from ._ui import PreviewImage
value = await current_runtime().refs.resolve(images)
return PreviewImage(
value, animated=animated, cls=self._metadata_owner).as_dict()
async def preview_mask(self, mask: MaskRef,
animated: bool = False) -> dict:
from ._ui import PreviewMask
value = await current_runtime().refs.resolve(mask)
return PreviewMask(
value, animated=animated, cls=self._metadata_owner).as_dict()
async def preview_audio(self, audio: AudioRef) -> dict:
from ._ui import PreviewAudio
value = await current_runtime().refs.resolve(audio)
return PreviewAudio(value, cls=self._metadata_owner).as_dict()
async def preview_animation(
self, images: ImageRef, fps: float = 8.0,
) -> dict:
import math
import random
from ._io import FolderType
from ._ui import ImageSaveHelper, SavedImages
rate = float(fps)
if not math.isfinite(rate) or not 0.01 <= rate <= 1000.0:
raise ValueError("animation fps must be finite and in [0.01, 1000]")
value = await current_runtime().refs.resolve(images)
if len(value) == 0:
raise ValueError("animation needs at least one frame")
prefix = "AnimPreview_temp_" + "".join(
random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(5))
result = ImageSaveHelper.save_animated_webp(
value, filename_prefix=prefix, folder_type=FolderType.temp,
cls=self._metadata_owner, fps=rate, lossless=False, quality=50,
method=0)
return SavedImages(
[result], is_animated=len(value) != 1).as_dict() | {
"text": [
f"{len(value)}x{int(value[0].shape[1])}x"
f"{int(value[0].shape[0])}"
]
}
async def preview_batch(
self, value: TensorRef, max_thumb_size: int = 512,
crf: int = 25, max_grid_frames: int = 1024,
) -> dict:
import math
import random
from fractions import Fraction
import av
import numpy as np
import torch
import torch.nn.functional as functional
from PIL import Image
import comfy.model_management
import folder_paths
images = await current_runtime().refs.resolve(value)
if not isinstance(images, torch.Tensor) or images.ndim not in (3, 4):
raise TypeError("batch preview input must be an IMAGE or MASK tensor")
thumb = int(max_thumb_size)
quality = int(crf)
limit = int(max_grid_frames)
if not 512 <= thumb <= 1024:
raise ValueError("batch preview max_thumb_size must be in [512, 1024]")
if not 0 <= quality <= 51:
raise ValueError("batch preview CRF must be in [0, 51]")
if not 1 <= limit <= 4096:
raise ValueError("batch preview max_grid_frames must be in [1, 4096]")
if images.ndim == 3:
images = images.reshape(
(-1, 1, images.shape[-2], images.shape[-1])).movedim(
1, -1).expand(-1, -1, -1, 3)
if images.shape[0] == 0 or images.shape[-1] < 3:
raise ValueError("batch preview needs at least one RGB frame")
batch, height, width, _ = images.shape
if batch > limit:
indices = torch.linspace(0, batch - 1, limit).round().long().tolist()
else:
indices = list(range(batch))
total = len(indices)
scale = min(1.0, thumb / max(height, width))
new_width = max(2, int(round(width * scale)))
new_height = max(2, int(round(height * scale)))
new_width -= new_width & 1
new_height -= new_height & 1
strip_scale = min(1.0, 256 / max(new_height, new_width))
strip_width = max(2, int(round(new_width * strip_scale)))
strip_height = max(2, int(round(new_height * strip_scale)))
strip_columns = max(1, int(math.ceil(math.sqrt(total))))
strip_rows = int(math.ceil(total / strip_columns))
strip = np.zeros(
(strip_rows * strip_height, strip_columns * strip_width, 3),
dtype=np.uint8)
prefix = "kj_batch_preview_" + "".join(
random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(6))
full_folder, filename, counter, subfolder, _ = (
folder_paths.get_save_image_path(
prefix, folder_paths.get_temp_directory(),
new_width, new_height))
video_file = f"{filename}_{counter:05}_.mp4"
strip_file = f"{filename}_{counter:05}_grid.jpg"
video_path = os.path.join(full_folder, video_file)
strip_path = os.path.join(full_folder, strip_file)
container = None
try:
container = av.open(video_path, mode="w")
stream = container.add_stream("libx264", rate=Fraction(30, 1))
stream.width = new_width
stream.height = new_height
stream.pix_fmt = "yuv420p"
stream.options = {
"crf": str(quality), "preset": "ultrafast", "g": "1",
"tune": "fastdecode",
}
work_device = comfy.model_management.get_torch_device()
selected = images[indices, ..., :3].permute(
0, 3, 1, 2).contiguous().to(work_device)
if (new_height, new_width) != (height, width):
mode = "area" if scale < 1.0 else "bilinear"
selected = functional.interpolate(
selected, size=(new_height, new_width), mode=mode)
video_frames = selected.mul(255).clamp(0, 255).to(
dtype=torch.uint8, device="cpu").permute(
0, 2, 3, 1).contiguous().numpy()
if (strip_height, strip_width) != (new_height, new_width):
strip_tensor = functional.interpolate(
selected, size=(strip_height, strip_width), mode="area")
strip_frames = strip_tensor.mul(255).clamp(0, 255).to(
dtype=torch.uint8, device="cpu").permute(
0, 2, 3, 1).contiguous().numpy()
else:
strip_frames = video_frames
for index, frame_array in enumerate(video_frames):
row = index // strip_columns
column = index % strip_columns
strip[
row * strip_height:(row + 1) * strip_height,
column * strip_width:(column + 1) * strip_width,
] = strip_frames[index]
frame = av.VideoFrame.from_ndarray(frame_array, format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
container.close()
container = None
Image.fromarray(strip).save(strip_path, quality=85)
except BaseException:
if container is not None:
container.close()
for path in (video_path, strip_path):
try:
os.unlink(path)
except FileNotFoundError:
pass
raise
return {"kj_batch_preview": [{
"filename": video_file,
"subfolder": subfolder,
"type": "temp",
"frame_count": total,
"fps": 30,
"thumb_w": new_width,
"thumb_h": new_height,
"strip_filename": strip_file,
"strip_cols": strip_columns,
"strip_cell_w": strip_width,
"strip_cell_h": strip_height,
}]}
class _InProcessOutput:
_TEXT_EXTENSIONS = frozenset({
".txt", ".caption", ".json", ".yaml", ".yml", ".md", ".csv",
".tsv", ".xml", ".log", ".ini", ".toml",
})
_STILL_FORMATS = {
"png": ("PNG", frozenset({".png"}), ".png"),
"jpg": ("JPEG", frozenset({".jpg", ".jpeg"}), ".jpg"),
"jpeg": ("JPEG", frozenset({".jpg", ".jpeg"}), ".jpg"),
"webp": ("WEBP", frozenset({".webp"}), ".webp"),
"j2k": ("JPEG2000", frozenset({".j2k", ".jp2"}), ".j2k"),
"jp2": ("JPEG2000", frozenset({".j2k", ".jp2"}), ".jp2"),
"gif": ("GIF", frozenset({".gif"}), ".gif"),
"tiff": ("TIFF", frozenset({".tiff"}), ".tiff"),
"bmp": ("BMP", frozenset({".bmp"}), ".bmp"),
"avif": ("AVIF", frozenset({".avif"}), ".avif"),
}
_IMAGE_BATCH_MAX = 4096
def __init__(self, prompt: Any = None, extra_pnginfo: Any = None) -> None:
self._prompt = prompt
self._extra_pnginfo = extra_pnginfo
self._metadata_owner = _image_metadata_owner(prompt, extra_pnginfo)
@staticmethod
def _prefix(filename_prefix: str, subfolder: str) -> str:
filename_prefix = str(filename_prefix)
subfolder = str(subfolder or "")
if subfolder in (".", "output"):
subfolder = ""
if os.path.isabs(subfolder) or os.path.isabs(filename_prefix):
raise ValueError("output names must be relative")
prefix = os.path.normpath(
os.path.join(subfolder, filename_prefix)
if subfolder else filename_prefix)
if prefix in ("", ".", os.pardir) or prefix.startswith(os.pardir + os.sep):
raise ValueError("output name must stay inside the output directory")
return prefix
@classmethod
def _extension(cls, extension: str) -> str:
extension = os.path.basename(str(extension))
if extension and not extension.startswith("."):
extension = "." + extension
extension = extension.lower()
if extension not in cls._TEXT_EXTENSIONS:
allowed = ", ".join(sorted(cls._TEXT_EXTENSIONS))
raise ValueError(
f"output text extension {extension!r} is not allowed; "
f"choose one of {allowed}")
return extension
@classmethod
def _still_format(cls, value: str) -> tuple[str, frozenset[str], str]:
key = str(value).lower()
result = cls._STILL_FORMATS.get(key)
if result is None:
allowed = ", ".join(cls._STILL_FORMATS)
raise ValueError(
f"still-image format {value!r} is not supported; "
f"choose one of {allowed}")
return result
@staticmethod
def _logical_output_target(
output_dir: str, filename: str, suffixes: frozenset[str],
) -> tuple[str, str, str, str]:
"""Validate one exact logical filename and resolve its confined target."""
logical = str(filename).replace("\\", "/")
if (
not logical or "\x00" in logical or logical.startswith("/")
or re.match(r"^[A-Za-z]:($|/)", logical)
or len(logical.encode("utf-8")) > 1024
):
raise ValueError("image filename must be a bounded relative path")
parts = logical.split("/")
if (
len(parts) > 32
or any(part in {"", ".", ".."} for part in parts)
or any(len(part.encode("utf-8")) > 255 for part in parts)
):
raise ValueError("image filename contains an unsafe path component")
suffix = os.path.splitext(parts[-1])[1].lower()
if suffix not in suffixes:
expected = ", ".join(sorted(suffixes))
raise ValueError(
f"image filename suffix {suffix!r} does not match "
f"the selected format ({expected})")
root = os.path.realpath(os.path.abspath(output_dir))
parent = os.path.realpath(os.path.join(root, *parts[:-1]))
try:
confined_parent = os.path.commonpath((root, parent)) == root
except ValueError:
confined_parent = False
if not confined_parent:
raise ValueError("image filename escapes the output directory")
os.makedirs(parent, exist_ok=True)
# Resolve once more after creation so an existing symlinked component
# cannot turn a relative name into ambient filesystem authority.
parent = os.path.realpath(os.path.join(root, *parts[:-1]))
target = os.path.realpath(os.path.join(parent, parts[-1]))
try:
confined_target = os.path.commonpath((root, target)) == root
except ValueError:
confined_target = False
if not confined_target:
raise ValueError("image filename escapes the output directory")
subfolder = "/".join(parts[:-1])
return target, logical, parts[-1], subfolder
@staticmethod
def _save_pil_exclusive(rendered: Any, target: str, format: str,
options: dict[str, Any]) -> None:
"""Encode beside the destination, then publish without overwriting."""
import tempfile
parent = os.path.dirname(target)
descriptor, temporary = tempfile.mkstemp(
prefix=".comfy-image-", suffix=".tmp", dir=parent)
try:
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
rendered.save(stream, format=format, **options)
stream.flush()
os.fsync(stream.fileno())
try:
os.link(temporary, target)
except FileExistsError as error:
raise FileExistsError(
f"output image already exists: "
f"{os.path.basename(target)!r}") from error
finally:
if descriptor >= 0:
os.close(descriptor)
try:
os.unlink(temporary)
except FileNotFoundError:
pass
async def save_images(
self, images: ImageRef, filename_prefix: str = "ComfyUI",
subfolder: str = "", compress_level: int = 4,
caption: Optional[str] = None,
caption_extension: str = ".txt",
save_metadata: bool = True,
extra_metadata: Optional[dict[str, Any]] = None,
image_format: str = "png", quality: int = 95,
filenames: Optional[list[str]] = None,
lossless: bool = False, optimize: bool = False,
) -> dict:
import numpy as np
from PIL import Image as PILImage
import folder_paths
from ._io import FolderType
from ._ui import ImageSaveHelper, SavedImages, SavedResult
value = await current_runtime().refs.resolve(images)
if not hasattr(value, "ndim") or int(value.ndim) != 4:
raise TypeError("saved IMAGE must contain a BHWC tensor")
batch_size = int(value.shape[0])
if not 1 <= batch_size <= self._IMAGE_BATCH_MAX:
raise ValueError(
f"saved IMAGE batch must be in [1, {self._IMAGE_BATCH_MAX}]")
if int(value.shape[-1]) not in (1, 3, 4):
raise ValueError("saved IMAGE must have 1, 3, or 4 channels")
level = int(compress_level)
if not 0 <= level <= 9:
raise ValueError("PNG compression level must be in [0, 9]")
pil_format, allowed_suffixes, default_suffix = self._still_format(
image_format)
quality = int(quality)
if not 1 <= quality <= 100:
raise ValueError("image quality must be in [1, 100]")
if type(lossless) is not bool or type(optimize) is not bool:
raise TypeError("lossless and optimize must be booleans")
metadata_owner = _image_metadata_owner(
self._prompt,
self._extra_pnginfo,
include_execution_metadata=bool(save_metadata),
extra_metadata=extra_metadata,
)
output_dir = folder_paths.get_output_directory()
requested: list[tuple[str, str, str, str]] = []
if filenames is not None:
if not isinstance(filenames, (list, tuple)):
raise TypeError("image filenames must be a sequence of strings")
if len(filenames) != batch_size:
raise ValueError(
"image filenames length must equal the IMAGE batch size")
if any(not isinstance(name, str) for name in filenames):
raise TypeError("every image filename must be a string")
requested = [
self._logical_output_target(output_dir, name, allowed_suffixes)
for name in filenames
]
targets = [entry[0] for entry in requested]
if len(set(targets)) != len(targets):
raise ValueError("image filenames must be unique within a batch")
if any(os.path.lexists(target) for target in targets):
raise FileExistsError("an exact output image already exists")
else:
prefix = self._prefix(filename_prefix, subfolder)
full_folder, filename, counter, saved_subfolder, _ = (
folder_paths.get_save_image_path(
prefix, folder_paths.get_output_directory(),
value[0].shape[1], value[0].shape[0]))
for batch_number in range(batch_size):
batch_name = filename.replace(
"%batch_num%", str(batch_number))
file = f"{batch_name}_{counter:05}_{default_suffix}"
logical = (
f"{str(saved_subfolder).replace(os.sep, '/')}/{file}"
if saved_subfolder else file)
requested.append(self._logical_output_target(
output_dir, logical, allowed_suffixes))
counter += 1
results = []
png_metadata = ImageSaveHelper._create_png_metadata(metadata_owner)
for image, (target, _logical, file, saved_subfolder) in zip(
value, requested):
array = np.clip(
255.0 * image.detach().cpu().numpy(), 0, 255
).astype(np.uint8)
if array.shape[-1] == 1:
array = array[..., 0]
rendered = PILImage.fromarray(array)
options: dict[str, Any] = {}
if pil_format in {"JPEG", "JPEG2000"} and rendered.mode != "RGB":
rendered = rendered.convert("RGB")
if pil_format in {"PNG", "GIF"}:
if png_metadata is not None:
options["pnginfo"] = png_metadata
options["optimize"] = optimize
if pil_format == "PNG":
options["compress_level"] = level
elif pil_format == "JPEG":
options.update(
quality=quality, optimize=optimize, subsampling=0)
elif pil_format in {"WEBP", "AVIF"}:
options.update(
quality=quality, lossless=lossless, optimize=optimize)
elif pil_format == "JPEG2000":
options["irreversible"] = not lossless
elif pil_format == "TIFF":
options["optimize"] = optimize
if pil_format in {"WEBP", "AVIF", "JPEG2000", "TIFF"}:
exif = ImageSaveHelper._create_webp_metadata(
rendered, metadata_owner)
if len(exif):
options["exif"] = exif.tobytes()
if pil_format == "JPEG":
# JPEG has a hard one-segment EXIF ceiling. Trusted workflow
# data can legitimately exceed it, so degrade metadata without
# failing the user's image save: prompt first, then broker
# workflow/extra data, then pack metadata. PNG/WebP keep their
# full metadata behavior.
owners = [metadata_owner]
if bool(save_metadata):
owners.extend((
_image_metadata_owner(
None,
self._extra_pnginfo,
include_execution_metadata=True,
extra_metadata=extra_metadata,
),
_image_metadata_owner(
None,
None,
include_execution_metadata=False,
extra_metadata=extra_metadata,
),
))
owners.append(None)
for owner in owners:
try:
jpeg_options = dict(options)
if owner is not None:
exif = ImageSaveHelper._create_webp_metadata(
rendered.copy(), owner)
if len(exif):
jpeg_options["exif"] = exif.tobytes()
self._save_pil_exclusive(
rendered, target, pil_format, jpeg_options)
break
except ValueError as error:
if "exif data is too long" not in str(error).lower():
raise
else: # the final no-EXIF attempt should make this unreachable
raise RuntimeError("JPEG image could not be encoded")
else:
self._save_pil_exclusive(
rendered, target, pil_format, options)
results.append(SavedResult(
file, saved_subfolder, FolderType.output))
if caption is not None:
extension = self._extension(caption_extension)
output_dir = os.path.abspath(folder_paths.get_output_directory())
for result in results:
stem = os.path.splitext(result.filename)[0]
target = os.path.abspath(os.path.join(
output_dir, result.subfolder, stem + extension))
if os.path.commonpath((output_dir, target)) != output_dir:
raise ValueError("caption target escapes the output directory")
with open(target, "w", encoding="utf-8") as file:
file.write(str(caption))
return SavedImages(results).as_dict()
async def save_images_with_alpha(
self, images: ImageRef, mask: MaskRef,
filename_prefix: str = "ComfyUI", subfolder: str = "",
compress_level: int = 4,
) -> dict:
import numpy as np
from PIL import Image as PILImage
import folder_paths
from ._io import FolderType
from ._ui import ImageSaveHelper, SavedImages, SavedResult
rt = current_runtime()
pixels = await rt.refs.resolve(images)
masks = await rt.refs.resolve(mask)
if len(pixels) != len(masks):
raise ValueError("image and alpha-mask batches must have equal length")
level = int(compress_level)
if not 0 <= level <= 9:
raise ValueError("PNG compression level must be in [0, 9]")
prefix = self._prefix(filename_prefix, subfolder)
full_folder, filename, counter, saved_subfolder, _ = (
folder_paths.get_save_image_path(
prefix, folder_paths.get_output_directory(),
pixels[0].shape[1], pixels[0].shape[0]))
metadata = ImageSaveHelper._create_png_metadata(self._metadata_owner)
results = []
for batch_number, (image, alpha) in enumerate(zip(pixels, masks)):
rgb = PILImage.fromarray(np.clip(
255.0 * image.cpu().numpy(), 0, 255).astype(np.uint8))
alpha_image = PILImage.fromarray(
np.clip(255.0 * (1.0 - alpha.cpu().float().numpy()), 0, 255)
.astype(np.uint8), mode="L")
if alpha_image.size != rgb.size:
alpha_image = alpha_image.resize(rgb.size, PILImage.Resampling.LANCZOS)
rgb.putalpha(alpha_image)
batch_name = filename.replace("%batch_num%", str(batch_number))
file = f"{batch_name}_{counter:05}_.png"
rgb.save(os.path.join(full_folder, file), pnginfo=metadata,
compress_level=level)
results.append(SavedResult(file, saved_subfolder, FolderType.output))
counter += 1
return SavedImages(results).as_dict()
async def save_text(
self, text: str, filename_prefix: str = "text",
subfolder: str = "", extension: str = ".txt",
) -> str:
import folder_paths
extension = self._extension(extension)
output_dir = os.path.abspath(folder_paths.get_output_directory())
full_folder, filename, counter, saved_subfolder, _ = (
folder_paths.get_save_image_path(
self._prefix(filename_prefix, subfolder), output_dir))
while True:
file = f"{filename}_{counter:05}_{extension}"
target = os.path.abspath(os.path.join(full_folder, file))
if os.path.commonpath((output_dir, target)) != output_dir:
raise ValueError("text target escapes the output directory")
if not os.path.exists(target):
break
counter += 1
with open(target, "w", encoding="utf-8") as stream:
stream.write(str(text))
return os.path.join(saved_subfolder, file) if saved_subfolder else file
async def write_text(
self, text: str, filename: str, folder: str = "output",
mode: str = "overwrite", insert_newline: bool = False,
) -> str:
"""Write a specifically named text artifact inside output or temp."""
import folder_paths
roots = {
"output": folder_paths.get_output_directory,
"temp": folder_paths.get_temp_directory,
}
if folder not in roots:
raise ValueError("text writes are limited to output or temp")
if mode not in {"append", "overwrite", "new_only"}:
raise ValueError("text write mode must be append, overwrite, or new_only")
if not isinstance(insert_newline, bool):
raise TypeError("insert_newline must be a boolean")
value = str(text)
if len(value.encode("utf-8")) > 16 * 1024 * 1024:
raise ValueError("text output exceeds the 16 MiB limit")
relative = os.path.normpath(str(filename))
if (os.path.isabs(relative) or relative in ("", ".", os.pardir)
or relative.startswith(os.pardir + os.sep)):
raise ValueError("text filename must stay inside its output folder")
self._extension(os.path.splitext(relative)[1])
root = os.path.realpath(os.path.abspath(roots[folder]()))
target = os.path.realpath(os.path.abspath(os.path.join(root, relative)))
if os.path.commonpath((root, target)) != root:
raise ValueError("text filename escapes its output folder")
os.makedirs(os.path.dirname(target), exist_ok=True)
if mode == "new_only":
with open(target, "x", encoding="utf-8") as stream:
stream.write(value)
elif mode == "append":
has_content = os.path.isfile(target) and os.path.getsize(target) > 0
with open(target, "a", encoding="utf-8") as stream:
if has_content and insert_newline:
stream.write("\n")
stream.write(value)
else:
with open(target, "w", encoding="utf-8") as stream:
stream.write(value)
return relative.replace(os.sep, "/")
async def save_workflow_json(
self, filename: str, mode: str = "new_only",
) -> str:
"""Write the broker-owned active workflow without revealing it.
The guest chooses only the confined logical output name and collision
mode. Prompt/workflow metadata remains on the trusted side.
"""
import json
if not isinstance(self._extra_pnginfo, dict):
raise ValueError("this execution has no workflow metadata")
workflow = self._extra_pnginfo.get("workflow")
if not isinstance(workflow, dict):
raise ValueError("this execution has no workflow object")
try:
encoded = json.dumps(
workflow, ensure_ascii=False, allow_nan=False, indent=2)
except (TypeError, ValueError) as error:
raise ValueError("workflow metadata is not JSON-compatible") from error
if not str(filename).lower().endswith(".json"):
raise ValueError("workflow sidecars must use the .json extension")
return await self.write_text(
encoded, filename=str(filename), folder="output", mode=str(mode))
@staticmethod
def _latent_preview(samples: Any, preview_method: str):
"""Render the sender's identifying preview without loading a model."""
import numpy as np
import torch
from PIL import Image as PILImage
import comfy.latent_formats as latent_formats
from latent_preview import Latent2RGBPreviewer
formats = {
"Latent2RGB-FLUX.1": latent_formats.Flux,
"Latent2RGB-SDXL": latent_formats.SDXL,
"Latent2RGB-SD15": latent_formats.SD15,
"Latent2RGB-SD3": latent_formats.SD3,
"Latent2RGB-SD-X4": latent_formats.SD_X4,
"Latent2RGB-Playground-2.5": latent_formats.SDXL_Playground_2_5,
"Latent2RGB-SC-Prior": latent_formats.SC_Prior,
"Latent2RGB-SC-B": latent_formats.SC_B,
"Latent2RGB-LTXV": latent_formats.LTXV,
# Impact's upstream implementation falls back to a linear preview
# for these labels when no approximate decoder is wired.
"TAEF1": latent_formats.Flux,
"TAESDXL": latent_formats.SDXL,
"TAESD15": latent_formats.SD15,
"TAESD3": latent_formats.SD3,
}
constructor = formats.get(str(preview_method))
if constructor is None:
allowed = ", ".join(sorted(formats))
raise ValueError(
f"unknown latent preview method {preview_method!r}; "
f"choose one of {allowed}")
latent_format = constructor()
try:
previewer = Latent2RGBPreviewer(
latent_format.latent_rgb_factors,
getattr(latent_format, "latent_rgb_factors_bias", None),
getattr(latent_format, "latent_rgb_factors_reshape", None),
)
image = previewer.decode_latent_to_preview(samples)
except Exception:
# Some third-party latent shapes do not have a matching published
# matrix. The preview is identification only; keep sharing the
# latent and render a bounded normalized RGB projection.
value = samples
if value.ndim == 5:
value = value[:, :, 0]
value = value[0].detach().float().cpu()
if value.shape[0] < 3:
value = value.expand(3, *value.shape[1:])
value = value[:3].movedim(0, -1)
low, high = value.amin(), value.amax()
value = (value - low) / (high - low) if high > low else value * 0
image = PILImage.fromarray(
value.mul(255).clamp(0, 255).to(torch.uint8).numpy())
minimum, maximum = min(image.size), max(image.size)
scale = min(1.0, 256.0 / max(1, maximum))
if minimum * scale < 128:
scale = 128.0 / max(1, minimum)
size = tuple(max(1, int(round(axis * scale))) for axis in image.size)
if size != image.size:
image = image.resize(size, resample=PILImage.Resampling.NEAREST)
array = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
return torch.from_numpy(array).unsqueeze(0)
async def save_latent(
self, latent: LatentRef,
filename_prefix: str = "latents/LatentSender",
preview_method: str = "Latent2RGB-SDXL",
) -> dict:
"""Save a temporary, safely loadable latent plus a visual preview."""
import os
import torch
import comfy.utils
import folder_paths
from ._io import FolderType
from ._ui import ImageSaveHelper, SavedImages
if not isinstance(latent, Ref) or latent.kind != "LATENT":
raise TypeError("save_latent requires a LATENT ref")
value = await current_runtime().refs.resolve(latent)
if not isinstance(value, dict) or "samples" not in value:
raise ValueError("latent value has no samples tensor")
samples = value["samples"]
if not isinstance(samples, torch.Tensor) or samples.ndim not in (4, 5):
raise ValueError("latent samples must be a 4D or 5D tensor")
prefix = self._prefix(filename_prefix, "")
temp_dir = folder_paths.get_temp_directory()
full_folder, filename, counter, subfolder, _ = (
folder_paths.get_save_image_path(prefix, temp_dir))
file = f"{filename}_{counter:05}_.latent"
target = os.path.abspath(os.path.join(full_folder, file))
temp_root = os.path.abspath(temp_dir)
if os.path.commonpath((temp_root, target)) != temp_root:
raise ValueError("latent target escapes the temp directory")
comfy.utils.save_torch_file({
"latent_tensor": samples.detach().contiguous().cpu(),
"latent_format_version_0": torch.tensor([]),
}, target)
artifact = {
"filename": file,
"subfolder": subfolder,
"type": "temp",
}
preview = self._latent_preview(samples, str(preview_method))
previews = ImageSaveHelper.save_images(
preview,
f"{prefix}_preview",
FolderType.temp,
self._metadata_owner,
4,
)
return {
"latents": [artifact],
"images": SavedImages(previews).as_dict()["images"],
"artifact": artifact,
}
async def save_state_dict(
self, state_dict: ValueRef, filename_prefix: str,
metadata: Optional[dict[str, str]] = None,
) -> str:
import torch
import comfy.utils
import folder_paths
value = await current_runtime().refs.resolve(state_dict)
if not isinstance(value, dict) or not value:
raise TypeError("state_dict must be a non-empty mapping")
if any(not isinstance(key, str) for key in value):
raise TypeError("state_dict keys must be strings")
if any(not isinstance(tensor, torch.Tensor) for tensor in value.values()):
raise TypeError("state_dict values must be tensors")
safe_metadata = None
if metadata is not None:
if not isinstance(metadata, dict) or any(
not isinstance(key, str) or not isinstance(item, str)
for key, item in metadata.items()
):
raise TypeError("state_dict metadata must map strings to strings")
safe_metadata = dict(metadata)
output_dir = os.path.abspath(folder_paths.get_output_directory())
full_folder, filename, counter, saved_subfolder, _ = (
folder_paths.get_save_image_path(
self._prefix(filename_prefix, ""), output_dir))
tensors = {
key: tensor.detach().contiguous().cpu()
for key, tensor in value.items()
}
while True:
file = f"{filename}_{counter:05}_.safetensors"
target = os.path.abspath(os.path.join(full_folder, file))
if os.path.commonpath((output_dir, target)) != output_dir:
raise ValueError("state_dict target escapes the output directory")
if not os.path.exists(target):
break
counter += 1
comfy.utils.save_torch_file(tensors, target, metadata=safe_metadata)
return os.path.join(saved_subfolder, file) if saved_subfolder else file
async def save_model(
self, model: ModelRef, filename_prefix: str,
model_key_prefix: str = "model.diffusion_model.",
) -> str:
import comfy.model_management
if type(filename_prefix) is not str:
raise TypeError("model filename_prefix must be a string")
if type(model_key_prefix) is not str:
raise TypeError("model_key_prefix must be a string")
if len(filename_prefix) > 4096:
raise ValueError("model filename_prefix is limited to 4096 characters")
if len(model_key_prefix) > 4096:
raise ValueError("model_key_prefix is limited to 4096 characters")
if any(ord(character) < 32 for character in filename_prefix):
raise ValueError("model filename_prefix contains control characters")
if ("/" in model_key_prefix or "\\" in model_key_prefix
or any(ord(character) < 32 for character in model_key_prefix)):
raise ValueError("model_key_prefix must be a tensor-key prefix")
prefix = self._prefix(filename_prefix, "")
rt = current_runtime()
if not isinstance(model, Ref) or model.kind != "MODEL":
raise TypeError("save_model requires a MODEL ref")
value = await rt.refs.resolve(model)
comfy.model_management.load_models_gpu([value])
source = value.state_dict_for_saving(None, None, None)
default_prefix = "model.diffusion_model."
state_dict = {}
for key, tensor in source.items():
output_key = (
model_key_prefix + key[len(default_prefix):]
if key.startswith(default_prefix) else key)
state_dict[output_key] = (
tensor if tensor.is_contiguous() else tensor.contiguous())
state_ref = ValueRef._wrap(await rt.refs.create("VALUE", state_dict))
try:
return await self.save_state_dict(
state_ref, prefix, metadata=None) # type: ignore[arg-type]
finally:
await rt.refs.release(state_ref)
@staticmethod
def _media_pixels(value: Any, operation: str):
import torch
if (not isinstance(value, torch.Tensor) or value.ndim != 4
or len(value) == 0 or value.shape[-1] < 3):
raise TypeError(f"{operation} needs non-empty BHWC image frames")
return value
def _media_target(
self, pixels: Any, filename_prefix: str, extension: str,
save_output: bool,
) -> tuple[str, str, str, Any]:
import folder_paths
from ._io import FolderType
extension = str(extension).lower().lstrip(".")
if not extension or not extension.isalnum():
raise ValueError("media extension must be alphanumeric")
folder_type = FolderType.output if bool(save_output) else FolderType.temp
output_dir = os.path.abspath(
folder_paths.get_output_directory()
if folder_type == FolderType.output
else folder_paths.get_temp_directory())
prefix = self._prefix(filename_prefix, "")
full_folder, filename, counter, subfolder, _ = (
folder_paths.get_save_image_path(
prefix, output_dir, pixels.shape[2], pixels.shape[1]))
while True:
file = f"{filename}_{counter:05}_.{extension}"
target = os.path.abspath(os.path.join(full_folder, file))
if os.path.commonpath((output_dir, target)) != output_dir:
raise ValueError("media target escapes its managed directory")
if not os.path.exists(target):
return target, file, subfolder, folder_type
counter += 1
def _media_metadata(self, enabled: bool = True) -> Optional[dict[str, Any]]:
from comfy.cli_args import args
if not enabled or args.disable_metadata:
return None
values = {}
if self._metadata_owner.hidden.extra_pnginfo is not None:
values.update(self._metadata_owner.hidden.extra_pnginfo)
if self._metadata_owner.hidden.prompt is not None:
values["prompt"] = self._metadata_owner.hidden.prompt
return values or None
async def save_animation(
self, images: ImageRef, fps: float = 8.0,
filename_prefix: str = "animation/ComfyUI",
format: str = "webp", loop_count: int = 0,
lossless: bool = True, quality: int = 90,
save_output: bool = True,
) -> dict:
import json
import math
import numpy as np
from PIL import Image as PILImage
from ._ui import SavedImages, SavedResult
rate = float(fps)
if not math.isfinite(rate) or not 0.01 <= rate <= 1000.0:
raise ValueError("animation fps must be finite and in [0.01, 1000]")
loops = int(loop_count)
if loops != loop_count or not 0 <= loops <= 100:
raise ValueError("animation loop_count must be an integer in [0, 100]")
quality_value = int(quality)
if quality_value != quality or not 0 <= quality_value <= 100:
raise ValueError("animation quality must be an integer in [0, 100]")
image_format = str(format).lower()
if image_format not in ("gif", "webp"):
raise ValueError("animation format must be 'gif' or 'webp'")
pixels = self._media_pixels(
await current_runtime().refs.resolve(images), "save_animation")
target, file, subfolder, folder_type = self._media_target(
pixels, filename_prefix, image_format, bool(save_output))
frames = []
for value in pixels:
array = np.clip(
value.detach().cpu().float().numpy() * 255.0,
0, 255).astype(np.uint8)
frames.append(PILImage.fromarray(
array[..., :4] if array.shape[-1] >= 4 else array[..., :3],
mode="RGBA" if array.shape[-1] >= 4 else "RGB"))
kwargs: dict[str, Any] = {
"save_all": True,
"append_images": frames[1:],
"duration": max(1, round(1000.0 / rate)),
"loop": loops,
}
metadata = self._media_metadata()
if image_format == "gif":
kwargs["disposal"] = 2
if metadata is not None:
kwargs["comment"] = json.dumps(
metadata, separators=(",", ":"), default=str
).encode("utf-8")[:65500]
else:
kwargs.update({
"lossless": bool(lossless),
"quality": quality_value,
"method": 4,
})
if metadata is not None:
exif = frames[0].getexif()
exif[0x0110] = "prompt:" + json.dumps(
metadata, separators=(",", ":"), default=str)
kwargs["exif"] = exif
try:
frames[0].save(target, format=image_format.upper(), **kwargs)
except BaseException:
try:
os.unlink(target)
except FileNotFoundError:
pass
raise
return SavedImages([
SavedResult(file, subfolder, folder_type),
], is_animated=len(frames) > 1).as_dict()
async def save_image_sequence(
self, images: ImageRef,
filename_prefix: str = "sequence/ComfyUI",
format: str = "png", bit_depth: int = 8,
save_output: bool = True,
) -> dict:
import av
import folder_paths
import numpy as np
from PIL import Image as PILImage
from ._io import FolderType
from ._ui import ImageSaveHelper, SavedImages, SavedResult
if str(format).lower() != "png":
raise ValueError("image-sequence format must be 'png'")
depth = int(bit_depth)
if depth != bit_depth or depth not in (8, 16):
raise ValueError("PNG sequence bit_depth must be 8 or 16")
pixels = self._media_pixels(
await current_runtime().refs.resolve(images),
"save_image_sequence")
folder_type = FolderType.output if bool(save_output) else FolderType.temp
output_dir = os.path.abspath(
folder_paths.get_output_directory()
if folder_type == FolderType.output
else folder_paths.get_temp_directory())
prefix = self._prefix(filename_prefix, "")
full_folder, filename, counter, subfolder, _ = (
folder_paths.get_save_image_path(
prefix, output_dir, pixels.shape[2], pixels.shape[1]))
while True:
stem = f"{filename}_{counter:05}_"
first_target = os.path.abspath(os.path.join(
full_folder, f"{stem}001.png"))
if os.path.commonpath((output_dir, first_target)) != output_dir:
raise ValueError("image sequence escapes its managed directory")
if not os.path.exists(first_target):
break
counter += 1
results = []
created = []
metadata = ImageSaveHelper._create_png_metadata(self._metadata_owner)
try:
for index, value in enumerate(pixels, 1):
file = f"{stem}{index:03d}.png"
target = os.path.abspath(os.path.join(full_folder, file))
if os.path.commonpath((output_dir, target)) != output_dir:
raise ValueError("image sequence escapes its managed directory")
array = np.clip(
value.detach().cpu().float().numpy()
* (65535.0 if depth == 16 else 255.0),
0, 65535 if depth == 16 else 255)
array = array[..., :3].astype(
np.uint16 if depth == 16 else np.uint8)
if depth == 8:
PILImage.fromarray(array, mode="RGB").save(
target, pnginfo=metadata, compress_level=4)
else:
with av.open(target, mode="w", format="image2") as output:
stream = output.add_stream("png", rate=1)
stream.width = int(array.shape[1])
stream.height = int(array.shape[0])
stream.pix_fmt = "rgb48be"
frame = av.VideoFrame.from_ndarray(
array, format="rgb48le")
for packet in stream.encode(frame):
output.mux(packet)
for packet in stream.encode(None):
output.mux(packet)
created.append(target)
results.append(SavedResult(file, subfolder, folder_type))
except BaseException:
for target in created:
try:
os.unlink(target)
except FileNotFoundError:
pass
raise
pattern = f"{stem}%03d.png"
return SavedImages(results).as_dict() | {"pattern": pattern}
async def save_video(
self, images: ImageRef, audio: Optional[AudioRef] = None,
fps: float = 25.0, filename_prefix: str = "video/ComfyUI",
format: str = "auto", codec: str = "auto",
encoder_options: Optional[dict[str, Any]] = None,
loop_count: int = 0, bit_depth: int = 8,
save_output: bool = True, save_metadata: bool = True,
) -> dict:
import json
import math
from fractions import Fraction
import av
import numpy as np
import torch
from ._ui import PreviewVideo, SavedResult
rate = float(fps)
if not math.isfinite(rate) or not 0.0 < rate <= 999.0:
raise ValueError("video fps must be finite and in (0, 999]")
loops = int(loop_count)
if loops != loop_count or not 0 <= loops <= 100:
raise ValueError("video loop_count must be an integer in [0, 100]")
depth = int(bit_depth)
if depth != bit_depth or depth not in (8, 16):
raise ValueError("video bit_depth must be 8 or 16")
if encoder_options is None:
options: dict[str, Any] = {}
elif type(encoder_options) is dict:
options = dict(encoder_options)
else:
raise TypeError("video encoder_options must be a dictionary")
allowed_option_names = frozenset({
"pixel_format", "crf", "bitrate_kbps", "profile", "level",
"coder", "context", "gop_size", "slices", "slice_crc",
})
unknown = set(options) - allowed_option_names
if unknown:
raise ValueError(
"unsupported video encoder option(s): "
+ ", ".join(sorted(map(str, unknown))))
containers = {
"mp4": ("mp4", "mp4"),
"webm": ("webm", "webm"),
"mkv": ("matroska", "mkv"),
"matroska": ("matroska", "mkv"),
"mov": ("mov", "mov"),
}
codecs = {
"h264": ("libx264", {"mp4"}, "yuv420p", {"yuv420p", "yuv420p10le"}),
"hevc": ("libx265", {"mp4"}, "yuv420p10le", {"yuv420p", "yuv420p10le"}),
"av1": ("libsvtav1", {"webm"}, "yuv420p", {"yuv420p", "yuv420p10le"}),
"vp9": ("libvpx-vp9", {"webm"}, "yuv420p", {"yuv420p", "yuva420p"}),
"prores": ("prores_ks", {"mov"}, "yuv422p10le", {"yuv422p10le", "yuv444p10le", "yuva444p10le"}),
"ffv1": ("ffv1", {"mkv"}, "rgba64le", {
"rgba64le", "bgra", "yuv420p", "yuv422p", "yuv444p",
"yuva420p", "yuva422p", "yuva444p", "yuv420p10le",
"yuv422p10le", "yuv444p10le", "yuv420p12le",
"yuv422p12le", "yuv444p12le", "yuv420p14le",
"yuv422p14le", "yuv444p14le", "yuv420p16le",
"yuv422p16le", "yuv444p16le", "gray", "gray10le",
"gray12le", "gray16le",
}),
"h264_nvenc": ("h264_nvenc", {"mp4"}, "yuv420p", {"yuv420p", "p010le"}),
"hevc_nvenc": ("hevc_nvenc", {"mp4"}, "yuv420p", {"yuv420p", "p010le"}),
"av1_nvenc": ("av1_nvenc", {"mp4"}, "yuv420p", {"yuv420p", "p010le"}),
}
codec_name = str(codec).lower()
container_name = str(format).lower()
if codec_name == "auto":
codec_name = {
"webm": "vp9", "mkv": "ffv1", "matroska": "ffv1",
"mov": "prores",
}.get(container_name, "h264")
if codec_name not in codecs:
raise ValueError(f"unsupported video codec {codec!r}")
if container_name == "auto":
container_name = {
"av1": "webm", "vp9": "webm", "ffv1": "mkv",
"prores": "mov",
}.get(codec_name, "mp4")
if container_name not in containers:
raise ValueError(f"unsupported video format {format!r}")
av_codec, compatible, default_pixel_format, allowed_pixel_formats = (
codecs[codec_name])
normalized_container = "mkv" if container_name == "matroska" else container_name
if normalized_container not in compatible:
raise ValueError(
f"video codec {codec_name!r} is not valid in "
f"{normalized_container!r}")
pixel_format = str(options.get(
"pixel_format", default_pixel_format)).lower()
if pixel_format not in allowed_pixel_formats:
raise ValueError(
f"pixel format {pixel_format!r} is not permitted for "
f"codec {codec_name!r}")
def integer_option(name: str, minimum: int, maximum: int) -> Optional[int]:
if name not in options:
return None
value = int(options[name])
if value != options[name] or not minimum <= value <= maximum:
raise ValueError(
f"video encoder option {name} must be an integer in "
f"[{minimum}, {maximum}]")
return value
crf = integer_option("crf", 0, 100)
bitrate_kbps = integer_option("bitrate_kbps", 1, 999000)
profile = str(options.get("profile", ""))
if profile and codec_name != "prores":
raise ValueError("video profile is currently supported only for ProRes")
profile_values = {"lt": 1, "standard": 2, "hq": 3, "4444": 4, "4444xq": 5}
if profile and profile not in profile_values:
raise ValueError("unknown ProRes profile")
ffv1_names = {"level", "coder", "context", "gop_size", "slices", "slice_crc"}
if set(options) & ffv1_names and codec_name != "ffv1":
raise ValueError("FFV1 tuning options require the ffv1 codec")
level = integer_option("level", 0, 3)
coder = integer_option("coder", 0, 2)
context_model = integer_option("context", 0, 1)
gop_size = integer_option("gop_size", 1, 300)
slices = integer_option("slices", 1, 30)
if slices is not None and slices not in {4, 6, 9, 12, 16, 20, 24, 30}:
raise ValueError("FFV1 slices must be one of 4, 6, 9, 12, 16, 20, 24, 30")
slice_crc = None
if "slice_crc" in options:
if type(options["slice_crc"]) is not bool:
raise TypeError("FFV1 slice_crc must be a boolean")
slice_crc = options["slice_crc"]
rt = current_runtime()
pixels = self._media_pixels(
await rt.refs.resolve(images), "save_video")
total_frames = len(pixels) * (loops + 1)
if total_frames > 1_000_000:
raise ValueError("video output is limited to 1,000,000 encoded frames")
audio_value = None
if audio is not None:
audio_value = await rt.refs.resolve(audio)
if (not isinstance(audio_value, dict)
or "waveform" not in audio_value
or "sample_rate" not in audio_value):
raise TypeError("save_video audio must contain waveform and sample_rate")
av_format, extension = containers[container_name]
target, file, subfolder, folder_type = self._media_target(
pixels, filename_prefix, extension, bool(save_output))
metadata = self._media_metadata(bool(save_metadata))
try:
try:
av.codec.Codec(av_codec, "w")
except Exception as exc:
raise RuntimeError(
f"video encoder {av_codec!r} is unavailable on this host"
) from exc
open_options = (
{"movflags": "use_metadata_tags"}
if normalized_container == "mp4" else None)
with av.open(
target, mode="w", format=av_format,
options=open_options,
) as output:
if metadata is not None:
for key, value in metadata.items():
# Match ComfyUI's VideoFromComponents contract: every
# metadata value is a JSON value, including strings.
output.metadata[str(key)] = json.dumps(
value, default=str)
frame_rate = Fraction(round(rate * 1000), 1000)
video_stream = output.add_stream(av_codec, rate=frame_rate)
width = int(pixels.shape[2])
height = int(pixels.shape[1])
alignment = 2 if any(
marker in pixel_format
for marker in ("420", "422", "p010")) else 1
encoded_width = width + (-width % alignment)
encoded_height = height + (-height % alignment)
video_stream.width = encoded_width
video_stream.height = encoded_height
video_stream.pix_fmt = pixel_format
stream_options = {}
if crf is not None:
stream_options["crf"] = str(crf)
if profile:
stream_options["profile"] = str(profile_values[profile])
for name, value in (
("level", level), ("coder", coder),
("context", context_model), ("g", gop_size),
("slices", slices),
):
if value is not None:
stream_options[name] = str(value)
if slice_crc is not None:
stream_options["slicecrc"] = "1" if slice_crc else "0"
if stream_options:
video_stream.options = stream_options
if bitrate_kbps is not None:
video_stream.bit_rate = bitrate_kbps * 1000
audio_stream = None
waveform = None
if audio_value is not None:
sample_rate = int(audio_value["sample_rate"])
if not 8000 <= sample_rate <= 192000:
raise ValueError("audio sample_rate must be in [8000, 192000]")
waveform = audio_value["waveform"]
if (not isinstance(waveform, torch.Tensor)
or waveform.ndim != 3 or len(waveform) == 0):
raise TypeError("audio waveform must have shape [batch, channels, samples]")
waveform = waveform[0].detach().cpu().float()
channels = int(waveform.shape[0])
layouts = {
1: "mono", 2: "stereo", 3: "3.0", 4: "quad",
5: "5.0", 6: "5.1", 7: "6.1", 8: "7.1",
}
if channels not in layouts:
raise ValueError("audio must contain between 1 and 8 channels")
audio_codec = {
"webm": "libopus", "mkv": "flac", "mov": "pcm_s16le",
}.get(normalized_container, "aac")
audio_stream = output.add_stream(
audio_codec, rate=sample_rate, layout=layouts[channels])
wants_alpha = pixel_format.startswith(("rgba", "bgra", "yuva"))
for _cycle in range(loops + 1):
for value in pixels:
array = value.detach().cpu().float().numpy()
if wants_alpha:
if array.shape[-1] < 4:
alpha = np.ones((*array.shape[:2], 1), dtype=array.dtype)
array = np.concatenate((array[..., :3], alpha), axis=-1)
else:
array = array[..., :4]
else:
array = array[..., :3]
if encoded_width != width or encoded_height != height:
array = np.pad(
array,
((0, encoded_height - height),
(0, encoded_width - width), (0, 0)),
mode="edge")
maximum = 65535.0 if depth == 16 else 255.0
array = np.clip(array * maximum, 0, maximum).astype(
np.uint16 if depth == 16 else np.uint8)
source_format = (
"rgba64le" if wants_alpha else "rgb48le"
) if depth == 16 else (
"rgba" if wants_alpha else "rgb24")
frame = av.VideoFrame.from_ndarray(
np.ascontiguousarray(array), format=source_format)
frame = frame.reformat(
width=encoded_width, height=encoded_height,
format=pixel_format)
for packet in video_stream.encode(frame):
output.mux(packet)
for packet in video_stream.encode(None):
output.mux(packet)
if audio_stream is not None and waveform is not None:
sample_rate = int(audio_value["sample_rate"])
required = math.ceil(total_frames * sample_rate / rate)
if waveform.shape[1] < required:
waveform = torch.nn.functional.pad(
waveform, (0, required - waveform.shape[1]))
else:
waveform = waveform[:, :required]
audio_frame = av.AudioFrame.from_ndarray(
waveform.contiguous().numpy(), format="fltp",
layout=audio_stream.layout.name)
audio_frame.sample_rate = sample_rate
audio_frame.pts = 0
for packet in audio_stream.encode(audio_frame):
output.mux(packet)
for packet in audio_stream.encode(None):
output.mux(packet)
except BaseException:
try:
os.unlink(target)
except FileNotFoundError:
pass
raise
return PreviewVideo([
SavedResult(file, subfolder, folder_type),
]).as_dict()
class _InProcessGraph:
def __init__(self, current_node_id: str, prompt: Any = None,
extra_pnginfo: Any = None, dynamic_prompt: Any = None) -> None:
self._current_node_id = str(current_node_id)
self._prompt = prompt if isinstance(prompt, dict) else {}
self._workflow = (
extra_pnginfo.get("workflow", {})
if isinstance(extra_pnginfo, dict) else {})
self._dynamic_prompt = dynamic_prompt
def _prompt_key(self, node_id: int | str) -> Optional[str]:
wanted = str(node_id)
if wanted in self._prompt:
return wanted
prefix = self._current_node_id.rsplit(":", 1)[0]
if ":" in self._current_node_id:
scoped = f"{prefix}:{wanted}"
if scoped in self._prompt:
return scoped
matches = [key for key in self._prompt if key.rsplit(":", 1)[-1] == wanted]
return matches[0] if len(matches) == 1 else None
async def current_node_id(self) -> str:
"""Return only this execution's node id, never the surrounding prompt."""
return self._current_node_id
async def input_label(
self, input_name: str, default: str = "",
) -> str:
"""Return one display label from this node's workflow metadata."""
input_name = str(input_name)
default = str(default)
if len(input_name) > 256 or len(default) > 256:
raise ValueError("graph input names and labels are limited to 256 characters")
candidates = list(self._workflow.get("nodes", []))
definitions = self._workflow.get("definitions", {})
for subgraph in definitions.get("subgraphs", []):
candidates.extend(subgraph.get("nodes", []))
wanted = self._current_node_id.rsplit(":", 1)[-1]
matches = [
node for node in candidates
if str(node.get("id")) in {self._current_node_id, wanted}
]
if len(matches) != 1:
return default
for slot in matches[0].get("inputs", []):
if slot.get("name") == input_name:
label = slot.get("label")
if isinstance(label, str) and len(label) <= 256:
return label
return default
return default
def _require_dynamic_prompt(self):
if self._dynamic_prompt is None:
raise RuntimeError(
"graph expansion requires ComfyUI's active dynamic prompt")
return self._dynamic_prompt
@staticmethod
def _expansion_link(value: Any, created: dict[str, Any]) -> Any:
if isinstance(value, dict) and set(value) == {"node", "output"}:
node_id = str(value["node"])
if node_id not in created:
raise KeyError(f"expansion references unknown node {node_id!r}")
index = int(value["output"])
if not 0 <= index <= 1024:
raise ValueError("expansion output index is out of range")
return created[node_id].out(index)
if isinstance(value, dict):
return {
key: _InProcessGraph._expansion_link(item, created)
for key, item in value.items()
}
if isinstance(value, tuple):
return tuple(
_InProcessGraph._expansion_link(item, created)
for item in value)
return value
async def expand_nodes(
self, nodes: list[dict[str, Any]], outputs: list[dict[str, Any]], *,
_external_node_types: frozenset[str] = frozenset(),
) -> dict[str, Any]:
"""Build a bounded declarative graph expansion.
Normal specs remain confined to the caller's pack namespace. A spec
with ``clone_input`` may clone only the producer already wired directly
to that named input on the current node. ``CreateList`` is the sole
core node allowed in an expansion; it is a side-effect-free collection
primitive and lets pack-side orchestration collect cloned outputs.
``_external_node_types`` is a trusted-host policy input, not part of the
guest API. The Secure Nodes transport fills it only from exact
``graph.expand.external:<node type>`` declarations after verifying that
each used target is another converted-pack proxy. Keeping the policy
here lets pack code retain orchestration while preventing it from
selecting arbitrary host or legacy nodes.
"""
dynprompt = self._require_dynamic_prompt()
if not isinstance(nodes, list) or not 1 <= len(nodes) <= 128:
raise ValueError("graph expansion must contain 1..128 nodes")
if not isinstance(outputs, list) or len(outputs) > 128:
raise ValueError("graph expansion has too many outputs")
if (not isinstance(_external_node_types, frozenset)
or len(_external_node_types) > 64
or not all(
isinstance(item, str)
and 1 <= len(item) <= 256
and "\x00" not in item
for item in _external_node_types
)):
raise ValueError("external expansion policy is invalid")
current = dynprompt.get_node(self._current_node_id)
current_type = str(current.get("class_type", ""))
namespace = current_type.split(" ", 1)[0]
if not namespace:
raise ValueError("current node has no expansion namespace")
from comfy_execution.graph_utils import GraphBuilder, is_link
graph = GraphBuilder()
created: dict[str, Any] = {}
clones: dict[str, tuple[dict[str, Any], str]] = {}
for spec in nodes:
if not isinstance(spec, dict):
raise TypeError("expansion node specs must be dictionaries")
local_id = str(spec.get("id", ""))
if not re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", local_id):
raise ValueError(
"expansion node ids must be bounded local identifiers")
if local_id in created:
raise ValueError(f"duplicate expansion node id {local_id!r}")
clone_input = spec.get("clone_input")
if clone_input is not None:
if set(spec) != {"id", "clone_input"}:
raise ValueError(
"clone specs accept only id and clone_input")
clone_input = str(clone_input)
if not re.fullmatch(r"[A-Za-z0-9_.* -]{1,256}", clone_input):
raise ValueError("invalid cloned input name")
source = current.get("inputs", {}).get(clone_input)
if not is_link(source):
raise ValueError(
f"current input {clone_input!r} is not a direct link")
source_id = str(source[0])
if source_id == self._current_node_id:
raise ValueError("an expansion cannot clone its current node")
source_node = dynprompt.get_node(source_id)
class_type = str(source_node.get("class_type", ""))
if not class_type or len(class_type) > 256:
raise ValueError("linked producer has an invalid node type")
clones[local_id] = (source_node, source_id)
else:
class_type = str(spec.get("class_type", ""))
if (
len(class_type) > 256
or (
not class_type.startswith(namespace + " ")
and class_type != "CreateList"
and class_type not in _external_node_types
)
):
raise ValueError(
"expansions may create only their own pack nodes or "
"host-approved converted-pack nodes")
created[local_id] = graph.node(class_type, local_id)
for spec in nodes:
local_id = str(spec["id"])
target = created[local_id]
if local_id in clones:
source_node, source_id = clones[local_id]
inputs = source_node.get("inputs", {})
if not isinstance(inputs, dict) or len(inputs) > 256:
raise ValueError(
"linked producer inputs must be a bounded mapping")
for name, value in inputs.items():
target.set_input(str(name), value)
target.set_override_display_id(
str(dynprompt.get_display_node_id(source_id)))
continue
inputs = spec.get("inputs", {})
if not isinstance(inputs, dict) or len(inputs) > 256:
raise ValueError("expansion node inputs must be a bounded mapping")
for name, value in inputs.items():
name = str(name)
if not re.fullmatch(r"[A-Za-z0-9_.* -]{1,256}", name):
raise ValueError("invalid expansion input name")
target.set_input(name, self._expansion_link(value, created))
result = [self._expansion_link(item, created) for item in outputs]
return {"result": result, "expand": graph.finalize()}
async def expand_loop(
self, flow: Any, values: list[Any],
) -> dict[str, Any]:
"""Clone the bounded body between a loop opener and this closer."""
from comfy_execution.graph_utils import GraphBuilder, is_link
dynprompt = self._require_dynamic_prompt()
if not is_link(flow) or int(flow[1]) != 0:
raise ValueError("loop flow must be the opener's raw output-0 link")
if not isinstance(values, list) or not 1 <= len(values) <= 100:
raise ValueError("loop expansion requires 1..100 carried values")
open_node = str(flow[0])
close_node = self._current_node_id
upstream: dict[str, list[str]] = {}
parent_ids: list[str] = []
def explore_dependencies(node_id: str) -> None:
node_info = dynprompt.get_node(node_id)
for value in node_info.get("inputs", {}).values():
if not is_link(value):
continue
parent_id = str(value[0])
display_id = str(dynprompt.get_display_node_id(parent_id))
display_node = dynprompt.get_node(display_id)
if display_node.get("class_type") not in {
"easy forLoopEnd", "easy whileLoopEnd",
}:
parent_ids.append(display_id)
if parent_id not in upstream:
upstream[parent_id] = []
explore_dependencies(parent_id)
upstream[parent_id].append(node_id)
explore_dependencies(close_node)
parent_set = set(parent_ids)
try:
import nodes as comfy_nodes
original = dynprompt.get_original_prompt()
for output_id, node_info in original.items():
node_class = comfy_nodes.NODE_CLASS_MAPPINGS.get(
node_info.get("class_type"))
if not bool(getattr(node_class, "OUTPUT_NODE", False)):
continue
for value in node_info.get("inputs", {}).values():
if not is_link(value) or value[0] not in parent_set:
continue
for parent_id in tuple(upstream):
display_id = str(dynprompt.get_display_node_id(parent_id))
if display_id == value[0] and output_id not in upstream[parent_id]:
child = str(output_id)
if "." in parent_id:
parts = parent_id.split(".")
parts[-1] = child
child = ".".join(parts)
upstream[parent_id].append(child)
except (ImportError, KeyError, TypeError):
pass
contained: dict[str, bool] = {}
def collect(node_id: str) -> None:
for child_id in upstream.get(node_id, ()):
if child_id not in contained:
contained[child_id] = True
collect(child_id)
collect(open_node)
contained[open_node] = True
contained[close_node] = True
if len(contained) > 512:
raise ValueError("loop body exceeds the 512-node expansion limit")
graph = GraphBuilder()
clones: dict[str, Any] = {}
for node_id in contained:
original_node = dynprompt.get_node(node_id)
clone_id = "Recurse" if node_id == close_node else node_id
clone = graph.node(original_node["class_type"], clone_id)
clone.set_override_display_id(node_id)
clones[node_id] = clone
for node_id, clone in clones.items():
original_node = dynprompt.get_node(node_id)
for name, value in original_node.get("inputs", {}).items():
if is_link(value) and str(value[0]) in clones:
clone.set_input(name, clones[str(value[0])].out(int(value[1])))
else:
clone.set_input(name, value)
opener = clones[open_node]
for index, value in enumerate(values):
opener.set_input(f"initial_value{index}", value)
recurse = clones[close_node]
return {
"result": [recurse.out(index) for index in range(len(values))],
"expand": graph.finalize(),
}
def _id_for_title(self, title: str) -> Optional[int | str]:
candidates = list(self._workflow.get("nodes", []))
definitions = self._workflow.get("definitions", {})
for subgraph in definitions.get("subgraphs", []):
candidates.extend(subgraph.get("nodes", []))
matches = [node.get("id") for node in candidates
if node.get("title") == title]
return matches[0] if len(matches) == 1 else None
def _id_for_name(self, name: str) -> Optional[int | str]:
"""Resolve the visible type, S&R name, or title of one workflow node."""
candidates = list(self._workflow.get("nodes", []))
definitions = self._workflow.get("definitions", {})
for subgraph in definitions.get("subgraphs", []):
candidates.extend(subgraph.get("nodes", []))
matches = []
for node in candidates:
visible_name = node.get("type")
properties = node.get("properties")
if isinstance(properties, dict):
search_name = properties.get("Node name for S&R")
if isinstance(search_name, str) and search_name:
visible_name = search_name
if visible_name == name or node.get("title") == name:
matches.append(node.get("id"))
unique = list(dict.fromkeys(matches))
return unique[0] if len(unique) == 1 else None
async def widget_values(
self, node_id: int | str = 0, node_title: str = "",
node_name: str = "", linked_input: str = "any_input",
) -> dict[str, Any]:
target = None
if node_title and node_name:
raise ValueError("choose node_title or node_name, not both")
if node_title:
target = self._id_for_title(str(node_title))
if target is None:
raise KeyError(f"no unique workflow node titled {node_title!r}")
elif node_name:
target = self._id_for_name(str(node_name))
if target is None:
raise KeyError(f"no unique workflow node named {node_name!r}")
elif str(node_id) not in ("", "0"):
target = node_id
else:
current = self._prompt.get(self._current_node_id, {})
link = current.get("inputs", {}).get(linked_input)
if not (isinstance(link, (list, tuple)) and len(link) >= 1):
raise KeyError(
f"input {linked_input!r} on node {self._current_node_id} "
"is not linked")
target = link[0]
if self._dynamic_prompt is not None:
target_id = str(target)
if self._dynamic_prompt.has_node(target_id):
values = self._dynamic_prompt.get_node(target_id).get("inputs")
if isinstance(values, dict):
return dict(values)
key = self._prompt_key(target)
if key is None:
raise KeyError(f"node {target!r} is not present in this prompt")
values = self._prompt.get(key, {}).get("inputs")
if not isinstance(values, dict):
raise KeyError(f"node {target!r} has no prompt inputs")
return dict(values)
async def block(self, reason: Optional[str] = None) -> Any:
"""Return ComfyUI's branch-local execution blocker."""
from comfy_execution.graph_utils import ExecutionBlocker
if reason is not None and not isinstance(reason, str):
raise TypeError("execution blocker reason must be a string or None")
if isinstance(reason, str) and len(reason) > 4096:
raise ValueError("execution blocker reason exceeds 4096 characters")
return ExecutionBlocker(reason)
class _InProcessProgress:
def __init__(self, node_id: Optional[str]) -> None:
self._node_id = node_id
async def update(self, value: float, total: float,
preview: Optional[ImageRef] = None) -> None:
from comfy.utils import ProgressBar # lazy
preview_value = None
if preview is not None:
if not isinstance(preview, Ref) or preview.kind != "IMAGE":
raise TypeError("progress preview must be an IMAGE ref")
import numpy as np
import torch
from PIL import Image
from comfy.cli_args import args
image = torch.as_tensor(
await current_runtime().refs.resolve(preview)).detach()
if image.ndim == 4:
if image.shape[0] < 1:
raise ValueError("progress preview image batch is empty")
image = image[0]
if image.ndim != 3 or image.shape[-1] not in (1, 3, 4):
raise ValueError(
"progress preview must have HWC or BHWC image layout")
if image.shape[0] < 1 or image.shape[1] < 1:
raise ValueError("progress preview image is empty")
if image.numel() > 128 * 1024 * 1024:
raise ValueError("progress preview image is too large")
image = image.to(device="cpu", dtype=torch.float32)
image = torch.nan_to_num(image).clamp(0.0, 1.0)
array = (image * 255.0).round().to(torch.uint8).numpy()
if array.shape[-1] == 1:
array = np.squeeze(array, axis=-1)
preview_value = (
"PNG",
Image.fromarray(array),
args.preview_size,
)
pb = ProgressBar(total, node_id=self._node_id)
pb.update_absolute(value, total, preview=preview_value)
class _InProcessScratch:
async def dir(self) -> str:
import folder_paths # lazy
return folder_paths.get_temp_directory()
class _InProcessEvents:
async def emit(self, event: str, data: dict) -> None:
from server import PromptServer # lazy
inst = getattr(PromptServer, "instance", None)
if inst is not None:
inst.send_sync(event, data)
class _InProcessExecution:
"""Prompt-scoped execution control for nodes whose purpose is to stop."""
def __init__(self, prompt_id: str) -> None:
self._prompt_id = str(prompt_id)
async def interrupt(self) -> bool:
from server import PromptServer
instance = getattr(PromptServer, "instance", None)
queue = getattr(instance, "prompt_queue", None)
targeted = getattr(queue, "interrupt_if_running", None)
if callable(targeted):
return bool(targeted(self._prompt_id))
# Embedded/in-process hosts may not own a PromptQueue. In that case
# this context itself is the only active execution owner.
import nodes
nodes.interrupt_processing()
return True
class _InProcessSystem:
async def stats(self) -> dict[str, Any]:
"""Return the bounded resource totals already exposed by ComfyUI."""
import comfy.model_management as model_management
primary = model_management.get_torch_device()
cpu = model_management.torch.device("cpu")
devices = list(model_management.get_all_torch_devices())
if primary in devices:
devices = [primary, *(device for device in devices if device != primary)]
else:
devices.insert(0, primary)
entries = []
for device in devices:
total, torch_total = model_management.get_total_memory(
device, torch_total_too=True)
free, torch_free = model_management.get_free_memory(
device, torch_free_too=True)
entries.append({
"name": model_management.get_torch_device_name(device),
"type": device.type,
"index": device.index,
"vram_total": int(total),
"vram_free": int(free),
"torch_vram_total": int(torch_total),
"torch_vram_free": int(torch_free),
})
return {
"system": {
"ram_total": int(model_management.get_total_memory(cpu)),
"ram_free": int(model_management.get_free_memory(cpu)),
},
"devices": entries,
}
async def monitor(self) -> dict[str, Any]:
from comfy.system_monitor import get_system_monitor_snapshot
return get_system_monitor_snapshot()
class _StubDomain:
def __init__(self, name: str) -> None:
self._name = name
def __getattr__(self, item: str) -> Any:
raise NotImplementedError(
f"ctx.{self._name}.{item} is defined in the SDK contract but not yet "
f"implemented by the in-process default. Provided by the full SDK / "
f"overlay."
)
class _InProcessClosures:
"""Trusted/default execution of the same closed node-closure contract.
The isolated overlay supplies the prompt-scoped process boundary. The
ordinary in-process SDK needs the author surface to remain functional too;
here the resolver owns the entry and the cloned model owns the callback,
matching normal ComfyUI custom-node lifetime.
"""
async def retain(self, kind: str, fn: Callable, *, captures=None):
from . import _node_closures
if not callable(fn):
raise TypeError("a node closure needs a callable")
spec = _node_closures.get_kind(kind)
declared = spec.validate_captures(captures)
resolved = {}
for name, value in declared.items():
if isinstance(value, list):
resolved[name] = [
await current_runtime().refs.resolve(item) for item in value
]
elif value is not None:
resolved[name] = await current_runtime().refs.resolve(value)
return ClosureRef._wrap(await current_runtime().refs.create(
"CLOSURE", {
"kind": kind,
"fn": fn,
"captures": resolved,
},
))
async def attach_model(self, closure: ClosureRef, model: ModelRef):
if not isinstance(closure, ClosureRef):
raise TypeError("closure must be a typed CLOSURE ref")
if not isinstance(model, ModelRef):
raise TypeError("model must be a typed MODEL ref")
entry = await current_runtime().refs.resolve(closure)
kind = entry.get("kind")
if kind not in {
"post_cfg", "pre_cfg", "conditioning_selection",
"conditioning_preprocess",
"model_input_block", "model_middle_block", "model_output_block",
}:
raise ValueError(
"this node closure kind cannot attach to a model")
model_obj = await current_runtime().refs.resolve(model)
fn = entry["fn"]
if kind in {
"model_input_block", "model_middle_block", "model_output_block",
}:
import torch
from comfy.ldm.modules.diffusionmodules.openaimodel import UNetModel
if not hasattr(model_obj, "clone") or not hasattr(
model_obj, "get_model_object"
):
raise TypeError(
"MODEL does not expose a canonical diffusion model")
diffusion = model_obj.get_model_object("diffusion_model")
if not isinstance(diffusion, UNetModel):
raise TypeError(
"model block closures support only canonical 2D UNetModel")
input_count = len(diffusion.input_blocks)
output_count = len(diffusion.output_blocks)
if input_count + output_count + 1 > 256:
raise ValueError(
"canonical UNet exposes more than 256 block hooks")
hook_names = {
"model_input_block": "set_model_input_block_patch",
"model_middle_block": "set_model_middle_block_after_patch",
"model_output_block": "set_model_output_block_patch",
}
hook_name = hook_names[kind]
if not hasattr(model_obj, hook_name):
raise TypeError(
f"MODEL does not expose the canonical {kind} hook")
def block_metadata(options, phase, count):
sigmas = options.get("sigmas")
if not isinstance(sigmas, torch.Tensor) or sigmas.numel() < 1:
raise TypeError("model block hook sigmas must be nonempty")
block = options.get("block")
if not (
isinstance(block, tuple)
and len(block) == 2
and block[0] == phase
and isinstance(block[1], int)
and not isinstance(block[1], bool)
and 0 <= block[1] < count
):
raise TypeError(
f"model block hook has invalid {phase} block metadata")
return sigmas, int(block[1])
def validate(actual, expected):
if expected is None:
if actual is not None:
raise TypeError(
f"{kind} node closure changed a None skip")
return
if isinstance(expected, tuple):
if not isinstance(actual, tuple) or len(actual) != len(expected):
raise TypeError(
f"{kind} node closure must preserve its tensor pair")
for value, original in zip(
actual, expected, strict=True
):
validate(value, original)
return
if not (
isinstance(actual, torch.Tensor)
and tuple(actual.shape) == tuple(expected.shape)
and actual.dtype == expected.dtype
and str(actual.device) == str(expected.device)
):
raise TypeError(
f"{kind} node closure must preserve shape, dtype, "
"and device")
def input_block(hidden, options):
sigmas, index = block_metadata(
options, "input", input_count)
result = fn(hidden, sigmas, index)
if isinstance(result, Awaitable):
raise TypeError(
"model-block closures must be synchronous")
validate(result, hidden)
return result
def middle_block(args):
sigmas, index = block_metadata(
args["transformer_options"], "middle", 1)
result = fn(args["h"], sigmas, index)
if isinstance(result, Awaitable):
raise TypeError(
"model-block closures must be synchronous")
validate(result, args["h"])
return {"h": result}
def output_block(hidden, skip, options):
sigmas, index = block_metadata(
options, "output", output_count)
result = fn(hidden, skip, sigmas, index)
if isinstance(result, Awaitable):
raise TypeError(
"model-block closures must be synchronous")
validate(result, (hidden, skip))
return result
patched = model_obj.clone()
getattr(patched, hook_name)({
"model_input_block": input_block,
"model_middle_block": middle_block,
"model_output_block": output_block,
}[kind])
return ModelRef._wrap(await current_runtime().refs.create(
"MODEL", patched))
if kind == "conditioning_selection":
from comfy.samplers import calc_cond_batch
if not hasattr(model_obj, "clone") or not hasattr(
model_obj, "set_model_sampler_calc_cond_batch_function"
):
raise TypeError(
"MODEL does not expose conditional-batch selection")
patched = model_obj.clone()
previous = patched.model_options.get(
"sampler_calc_cond_batch_function")
def select_conditioning(args):
presence = [value is not None for value in args["conds"]]
sigma = args["sigma"]
if hasattr(sigma, "reshape"):
sigma = sigma.reshape(-1)[0]
if hasattr(sigma, "item"):
sigma = sigma.item()
selected = fn(presence, float(sigma))
if isinstance(selected, Awaitable):
raise TypeError(
"conditioning-selection closures must be synchronous")
if (
not isinstance(selected, list)
or len(selected) != len(presence)
or not all(type(value) is bool for value in selected)
or any(value and not original for value, original in zip(
selected, presence, strict=True))
):
raise TypeError(
"conditioning-selection closure returned invalid presence")
next_args = dict(args)
next_args["conds"] = [
value if keep else None
for value, keep in zip(args["conds"], selected, strict=True)
]
if previous is not None:
return previous(next_args)
return calc_cond_batch(
next_args["model"], next_args["conds"],
next_args["input"], next_args["sigma"],
next_args["model_options"],
)
patched.set_model_sampler_calc_cond_batch_function(
select_conditioning)
return ModelRef._wrap(await current_runtime().refs.create(
"MODEL", patched))
if kind == "conditioning_preprocess":
import copy
import torch
from comfy.samplers import calc_cond_batch
if not hasattr(model_obj, "clone") or not hasattr(
model_obj, "set_model_sampler_calc_cond_batch_function"
):
raise TypeError(
"MODEL does not expose conditional-batch preprocessing")
patched = model_obj.clone()
previous = patched.model_options.get(
"sampler_calc_cond_batch_function")
def preprocess_conditioning(args):
conds = copy.deepcopy(args["conds"])
selected = []
for cond in conds:
if cond is None:
continue
for item in cond:
model_conds = item.get("model_conds", {})
for key, wrapper in model_conds.items():
if key not in {"c_concat", "c_crossattn"}:
continue
tensor = getattr(wrapper, "cond", None)
if not isinstance(tensor, torch.Tensor) or not callable(
getattr(wrapper, "_copy_with", None)
):
raise TypeError(
"conditioning preprocessing requires canonical "
"tensor conditioning wrappers")
selected.append((model_conds, key, wrapper, tensor))
if selected:
tensors = [entry[3] for entry in selected]
noises = [torch.randn_like(tensor) for tensor in tensors]
result = fn(tensors, noises, args["sigma"])
if isinstance(result, Awaitable):
raise TypeError(
"conditioning-preprocess closures must be synchronous")
def validate(actual, expected):
if not isinstance(actual, list) or len(actual) != len(expected):
raise TypeError(
"conditioning-preprocess closure must preserve the "
"tensor list")
for value, original in zip(
actual, expected, strict=True
):
if not (
isinstance(value, torch.Tensor)
and tuple(value.shape) == tuple(original.shape)
and value.dtype == original.dtype
and str(value.device) == str(original.device)
):
raise TypeError(
"conditioning-preprocess closure must preserve "
"shape, dtype, and device")
validate(result, tensors)
for entry, value in zip(selected, result, strict=True):
model_conds, key, wrapper, _tensor = entry
model_conds[key] = wrapper._copy_with(value)
next_args = dict(args)
next_args["conds"] = conds
if previous is not None:
return previous(next_args)
return calc_cond_batch(
next_args["model"], next_args["conds"],
next_args["input"], next_args["sigma"],
next_args["model_options"],
)
patched.set_model_sampler_calc_cond_batch_function(
preprocess_conditioning)
return ModelRef._wrap(await current_runtime().refs.create(
"MODEL", patched))
hook_name = (
"set_model_sampler_post_cfg_function"
if kind == "post_cfg"
else "set_model_sampler_pre_cfg_function"
)
if not hasattr(model_obj, "clone") or not hasattr(model_obj, hook_name):
raise TypeError(
f"MODEL does not expose the canonical {kind.replace('_', '-')} hook")
def validate(actual, expected):
if isinstance(expected, (list, tuple)):
if not isinstance(actual, type(expected)) or len(actual) != len(expected):
raise TypeError(
f"{kind} node closure must preserve the prediction list")
for item, expected_item in zip(actual, expected, strict=True):
validate(item, expected_item)
return
if not (
hasattr(actual, "shape")
and tuple(actual.shape) == tuple(expected.shape)
and getattr(actual, "dtype", None) == expected.dtype
and str(getattr(actual, "device", "")) == str(expected.device)
):
raise TypeError(
f"{kind} node closure must preserve shape, dtype, and device")
def post_cfg(args):
result = fn(
args["denoised"],
args.get("cond_denoised"),
args.get("uncond_denoised"),
args["input"],
args["sigma"],
float(args["cond_scale"]),
)
if isinstance(result, Awaitable):
raise TypeError(
"tensor-phase node closures must be synchronous functions")
validate(result, args["denoised"])
return result
def pre_cfg(args):
expected = list(args["conds_out"])
result = fn(
args["input"],
expected,
[value is not None for value in args["conds"]],
args["sigma"],
)
if isinstance(result, Awaitable):
raise TypeError(
"tensor-phase node closures must be synchronous functions")
validate(result, expected)
return result
patched = model_obj.clone()
getattr(patched, hook_name)(
post_cfg if kind == "post_cfg" else pre_cfg,
disable_cfg1_optimization=True,
)
return ModelRef._wrap(await current_runtime().refs.create(
"MODEL", patched))
async def attach_sampler(
self, closure: ClosureRef, sampler: SamplerRef, *,
start_percent=None, end_percent=None,
):
import math
from comfy.samplers import KSAMPLER
if not isinstance(closure, ClosureRef):
raise TypeError("closure must be a typed CLOSURE ref")
if not isinstance(sampler, SamplerRef):
raise TypeError("sampler must be a typed SAMPLER ref")
entry = await current_runtime().refs.resolve(closure)
if entry.get("kind") != "model_sigma":
raise ValueError(
"only model_sigma node closures can wrap samplers")
sampler_obj = await current_runtime().refs.resolve(sampler)
for name, value in {
"start_percent": start_percent,
"end_percent": end_percent,
}.items():
if value is not None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{name} must be numeric")
value = float(value)
if not math.isfinite(value) or not 0.0 <= value <= 1.0:
raise ValueError(f"{name} must be finite and in [0, 1]")
if (start_percent is None) != (end_percent is None):
raise ValueError(
"start_percent and end_percent must be supplied together")
fn = entry["fn"]
def wrapped_sampler(model_fn, x, sigmas, **kwargs):
cfg = getattr(getattr(model_fn, "inner_model", None), "cfg", 1.0)
cfg = float(cfg) if isinstance(cfg, (int, float)) else 1.0
start_sigma = end_sigma = None
if start_percent is not None:
sampling = getattr(
getattr(getattr(model_fn, "inner_model", None),
"inner_model", None),
"model_sampling", None,
)
if sampling is None or not hasattr(sampling, "percent_to_sigma"):
raise TypeError(
"sampler model does not expose percent-to-sigma projection")
start_sigma = round(float(
sampling.percent_to_sigma(float(start_percent))), 4)
end_sigma = round(float(
sampling.percent_to_sigma(float(end_percent))), 4)
def model_wrapper(latent, sigma, **extra_args):
adjusted = fn(
sigma, sigmas, cfg, start_sigma, end_sigma)
if isinstance(adjusted, Awaitable):
raise TypeError(
"tensor-phase node closures must be synchronous functions")
if not (
hasattr(adjusted, "shape")
and tuple(adjusted.shape) == tuple(sigma.shape)
and adjusted.dtype == sigma.dtype
and str(adjusted.device) == str(sigma.device)
):
raise TypeError(
"model_sigma node closure must preserve shape, dtype, "
"and device")
return model_fn(latent, adjusted, **extra_args)
for name in ("inner_model", "sigmas"):
if hasattr(model_fn, name):
setattr(model_wrapper, name, getattr(model_fn, name))
return sampler_obj.sampler_function(
model_wrapper,
x,
sigmas,
**kwargs,
**sampler_obj.extra_options,
)
value = KSAMPLER(wrapped_sampler)
return SamplerRef._wrap(await current_runtime().refs.create(
"SAMPLER", value))
async def create_latent_operation(self, closure: ClosureRef):
if not isinstance(closure, ClosureRef):
raise TypeError("closure must be a typed CLOSURE ref")
entry = await current_runtime().refs.resolve(closure)
if entry.get("kind") != "latent_operation":
raise ValueError(
"only latent_operation closures can create LATENT_OPERATION")
fn = entry["fn"]
def operation(latent, **_kwargs):
result = fn(latent)
if isinstance(result, Awaitable):
raise TypeError(
"tensor-phase node closures must be synchronous functions")
if not (
hasattr(result, "shape")
and tuple(result.shape) == tuple(latent.shape)
and getattr(result, "dtype", None) == latent.dtype
and str(getattr(result, "device", "")) == str(latent.device)
):
raise TypeError(
"latent_operation closure must preserve shape, dtype, "
"and device")
return result
return LatentOperationRef._wrap(
await current_runtime().refs.create("LATENT_OPERATION", operation))
async def create_sampler(self, closure: ClosureRef):
"""Default in-process adapter for a pack-owned sampling loop."""
import math
import torch
import torch.nn.functional as functional
import comfy.model_patcher
import comfy.model_sampling
from comfy.k_diffusion.sampling import (
BrownianTreeNoiseSampler, default_noise_sampler,
)
from comfy.samplers import KSAMPLER
if not isinstance(closure, ClosureRef):
raise TypeError("closure must be a typed CLOSURE ref")
entry = await current_runtime().refs.resolve(closure)
if entry.get("kind") != "custom_sampler":
raise ValueError(
"only custom_sampler closures can create a SAMPLER")
fn = entry["fn"]
def sampler_function(model_fn, latent, sigmas, **kwargs):
if not (
isinstance(sigmas, torch.Tensor)
and torch.is_floating_point(sigmas)
and sigmas.ndim == 1
and 2 <= len(sigmas) <= 4097
):
raise ValueError(
"custom-sampler sigmas must be a finite floating-point, "
"nonnegative, nonincreasing 2..4097 vector ending at zero")
sigma_values = sigmas.detach().to(
device="cpu", dtype=torch.float64)
if (
not torch.isfinite(sigma_values).all()
or bool((sigma_values < 0).any())
or bool((sigma_values[:-1] < sigma_values[1:]).any())
or not math.isclose(
float(sigma_values[-1]), 0.0, abs_tol=1e-8)
):
raise ValueError(
"custom-sampler sigmas must be a finite floating-point, "
"nonnegative, nonincreasing 2..4097 vector ending at zero")
if not (
isinstance(latent, torch.Tensor)
and torch.is_floating_point(latent)
and latent.ndim >= 3
):
raise TypeError(
"custom sampler needs a floating-point tensor latent")
extra_args = kwargs.get("extra_args")
if extra_args is None:
extra_args = {}
if not isinstance(extra_args, dict):
raise TypeError(
"custom sampler received invalid host extra_args")
extra_args = dict(extra_args)
callback = kwargs.get("callback")
seed = extra_args.get("seed")
if seed is not None and (
isinstance(seed, bool)
or not isinstance(seed, int)
or not 0 <= seed <= (1 << 64) - 1
):
raise ValueError(
"custom-sampler seed must be an unsigned 64-bit integer")
get_sampling = getattr(
getattr(getattr(model_fn, "inner_model", None),
"model_patcher", None),
"get_model_object", None,
)
if not callable(get_sampling):
raise TypeError(
"custom sampler model does not expose model sampling")
model_sampling = get_sampling("model_sampling")
def validate_value(value, *, allow_resize=False):
if not (
isinstance(value, torch.Tensor)
and torch.is_floating_point(value)
and value.dtype == latent.dtype
and str(value.device) == str(latent.device)
):
raise TypeError(
"custom-sampler latent changed type, dtype, or device")
shape = tuple(value.shape)
original = tuple(latent.shape)
if shape == original:
return
if not allow_resize or (
len(shape) != 4
or len(original) != 4
or shape[:2] != original[:2]
or any(item < 1 for item in shape[2:])
or shape[2] > original[2] * 2
or shape[3] > original[3] * 2
or shape[2] * shape[3] > original[2] * original[3] * 4
):
raise ValueError(
"custom-sampler temporary resize is outside its bounds")
def validate_result(value, expected, name):
if not (
isinstance(value, torch.Tensor)
and torch.is_floating_point(value)
and tuple(value.shape) == tuple(expected.shape)
and value.dtype == expected.dtype
and str(value.device) == str(expected.device)
):
raise TypeError(
f"custom-sampler {name} must preserve shape, dtype, "
"and device")
def scalar(value, name):
if isinstance(value, bool):
raise TypeError(f"{name} must be numeric")
if hasattr(value, "numel"):
if int(value.numel()) != 1:
raise ValueError(
f"{name} must contain exactly one value")
value = value.detach().reshape(-1)[0].item()
try:
result = float(value)
except (TypeError, ValueError) as error:
raise TypeError(f"{name} must be numeric") from error
if not math.isfinite(result):
raise ValueError(f"{name} must be finite")
return result
class Broker:
def __init__(self):
self.denoise_count = 0
self.noise_count = 0
self.preview_count = 0
self.schedule_count = 0
self.last_denoise = None
self.noise_samplers = {}
async def denoise(
self, value, sigma, *, capture_uncond=False,
resize_context=None,
):
if not isinstance(capture_uncond, bool):
raise TypeError(
"capture_uncond must be a boolean")
if self.denoise_count >= 3 * (len(sigmas) - 1):
raise RuntimeError(
"custom sampler exceeded its denoise budget")
sigma_value = scalar(sigma, "sigma")
if sigma_value < 0:
raise ValueError("custom-sampler sigma is invalid")
mode = "none" if resize_context is None else str(
resize_context)
if mode not in {"none", "nearest-exact"}:
raise ValueError("unsupported sampler resize context")
validate_value(
value, allow_resize=mode == "nearest-exact")
call_args = dict(extra_args)
def restore():
return None
if mode == "nearest-exact":
target = tuple(value.shape[-2:])
old_latent = getattr(model_fn, "latent_image", None)
old_noise = getattr(model_fn, "noise", None)
old_mask = call_args.get("denoise_mask")
def resized(item):
return (
None if item is None else
functional.interpolate(
item, size=target, mode=mode)
)
new_latent = resized(old_latent)
new_noise = resized(old_noise)
new_mask = resized(old_mask)
try:
model_fn.latent_image = new_latent
model_fn.noise = new_noise
if old_mask is not None:
call_args["denoise_mask"] = new_mask
except Exception:
model_fn.latent_image = old_latent
model_fn.noise = old_noise
raise
def restore():
model_fn.latent_image = old_latent
model_fn.noise = old_noise
try:
captured = [None]
if capture_uncond:
def capture(args):
captured[0] = args.get("uncond_denoised")
return args["denoised"]
call_args["model_options"] = (
comfy.model_patcher.
set_model_options_post_cfg_function(
dict(call_args.get("model_options") or {}),
capture,
disable_cfg1_optimization=True,
))
sigma_batch = torch.full(
(int(value.shape[0]),),
sigma_value,
dtype=value.dtype,
device=value.device,
)
denoised = model_fn(
value, sigma_batch, **call_args)
finally:
restore()
validate_result(denoised, value, "denoise result")
if captured[0] is not None:
validate_result(
captured[0], value, "unconditional result")
self.denoise_count += 1
self.last_denoise = [value, denoised, False]
return denoised, captured[0]
async def noise_like(
self, value, *, kind="independent", step=0,
sigma_from=0.0, sigma_to=0.0, purpose="sampler",
noise_device=None, seeded=False,
):
if self.noise_count >= 4 * (len(sigmas) - 1):
raise RuntimeError(
"custom sampler exceeded its noise budget")
validate_value(value)
if not isinstance(kind, str) or kind not in {
"independent", "ancestral", "brownian",
}:
raise ValueError(
"unsupported custom-sampler noise kind")
if isinstance(step, bool) or not isinstance(step, int):
raise TypeError("custom-sampler noise step is invalid")
if not 0 <= step < len(sigmas) - 1:
raise ValueError(
"custom-sampler noise step is out of range")
if not isinstance(purpose, str) or not 1 <= len(purpose) <= 128:
raise ValueError(
"custom-sampler noise purpose is invalid")
if noise_device not in {None, "cpu", "latent"}:
raise ValueError(
"custom-sampler noise device is invalid")
if not isinstance(seeded, bool):
raise TypeError(
"custom-sampler seeded flag must be a boolean")
sigma_from_value = scalar(sigma_from, "sigma_from")
sigma_to_value = scalar(sigma_to, "sigma_to")
if (
sigma_from_value < 0
or sigma_to_value < 0
):
raise ValueError(
"custom-sampler noise sigmas are invalid")
if kind == "brownian":
key = ("brownian", noise_device or "cpu")
sampler = self.noise_samplers.get(key)
if sampler is None:
positive = sigmas[sigmas > 0]
sampler = BrownianTreeNoiseSampler(
latent,
positive.min(),
sigmas.max(),
seed=seed,
cpu=(noise_device or "cpu") == "cpu",
)
self.noise_samplers[key] = sampler
else:
key = ("default", bool(seeded))
sampler = self.noise_samplers.get(key)
if sampler is None:
sampler = default_noise_sampler(
latent, seed=seed if seeded else None)
self.noise_samplers[key] = sampler
result = sampler(
sigma_from_value, sigma_to_value)
validate_result(result, value, "noise result")
self.noise_count += 1
return result
async def preview(
self, step, value, sigma, sigma_hat, denoised,
):
del value, denoised
if isinstance(step, bool) or not isinstance(step, int):
raise TypeError("custom-sampler preview step is invalid")
if not 0 <= step < len(sigmas) - 1:
raise ValueError(
"custom-sampler preview step is out of range")
sigma_value = scalar(sigma, "sigma")
sigma_hat_value = scalar(sigma_hat, "sigma_hat")
if sigma_value < 0 or sigma_hat_value < 0:
raise ValueError(
"custom-sampler preview sigmas are invalid")
if self.last_denoise is None or self.last_denoise[2]:
raise RuntimeError(
"preview must follow one unpreviewed denoise")
if self.preview_count >= 3 * (len(sigmas) - 1):
raise RuntimeError(
"custom sampler exceeded its preview budget")
if callback is not None:
callback({
"x": self.last_denoise[0],
"i": step,
"sigma": sigma_value,
"sigma_hat": sigma_hat_value,
"denoised": self.last_denoise[1],
})
self.last_denoise[2] = True
self.preview_count += 1
async def schedule_parameters(self, *, percent_offset=1e-4):
if self.schedule_count >= 4:
raise RuntimeError(
"custom sampler exceeded its schedule budget")
offset = scalar(percent_offset, "percent_offset")
if not 1e-8 <= offset <= 0.1:
raise ValueError("percent_offset is outside its bounds")
is_const = isinstance(
model_sampling, comfy.model_sampling.CONST)
noise_scale = float(getattr(
model_sampling, "noise_scale", 1.0))
if not math.isfinite(noise_scale) or abs(noise_scale) > 1e6:
raise ValueError(
"model sampling noise_scale is outside its bounds")
first_sigma = (
float(model_sampling.percent_to_sigma(offset))
if is_const else None
)
if first_sigma is not None and (
not math.isfinite(first_sigma) or first_sigma < 0
):
raise ValueError(
"model sampling returned an invalid first sigma")
self.schedule_count += 1
return {
"parameterization": "const" if is_const else "sigma",
"noise_scale": noise_scale,
"first_sigma": first_sigma,
}
result = fn(Broker(), latent, sigmas)
if isinstance(result, Awaitable):
result = asyncio.run(result)
validate_value(result)
return result
return SamplerRef._wrap(await current_runtime().refs.create(
"SAMPLER", KSAMPLER(sampler_function)))
class _InProcessWanVideo:
"""Read one scheduler-relevant scalar from a WanVideo model patcher.
Scheduler construction and refinement remain untrusted pack code. This
adapter only resolves the opaque ref and projects the fixed scalar that
WanVideo's CausVid schedule selects on.
"""
async def transformer_dim(self, model: Ref) -> int:
if not isinstance(model, Ref) or model.kind not in {"MODEL", "OPAQUE"}:
raise TypeError("WanVideo model must be an opaque model ref")
value = await current_runtime().refs.resolve(model)
try:
dimension = value.model.diffusion_model.dim
except AttributeError as error:
raise ValueError(
"WanVideo model does not publish transformer dimension"
) from error
if (isinstance(dimension, bool) or not isinstance(dimension, int)
or not 1 <= dimension <= 65_536):
raise ValueError("WanVideo transformer dimension is invalid")
return dimension
class _InProcessLlm:
"""Normalize common chat/tool semantics onto closed vendor adapters."""
@staticmethod
def _vendor_options(value: Any) -> tuple[int, str]:
if value is None:
return 5, "minutes"
if not isinstance(value, dict) or set(value) != {"ollama"}:
raise ValueError("LLM vendor_options must contain only ollama")
options = value["ollama"]
if not isinstance(options, dict) or not set(options) <= {
"keep_alive", "keep_alive_unit",
}:
raise ValueError("LLM Ollama vendor options are invalid")
return options.get("keep_alive", 5), options.get(
"keep_alive_unit", "minutes")
@staticmethod
def _messages(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list) or not 1 <= len(value) <= 256:
raise ValueError("LLM messages must contain 1 to 256 entries")
result = []
for message in value:
if not isinstance(message, dict):
raise ValueError("LLM message has an invalid shape")
role = message.get("role")
if role in {"system", "user"}:
if set(message) != {"role", "content"}:
raise ValueError("LLM message has an invalid shape")
result.append(dict(message))
continue
if role == "assistant":
if (not {"role", "content"}.issubset(message)
or not set(message) <= {
"role", "content", "thinking", "tool_calls",
}):
raise ValueError("LLM assistant message has an invalid shape")
result.append(dict(message))
continue
if role == "tool":
if set(message) != {"role", "name", "content"}:
raise ValueError("LLM tool message has an invalid shape")
result.append({
"role": "tool",
"tool_name": message["name"],
"content": message["content"],
})
continue
raise ValueError("LLM message role is invalid")
return result
async def chat(
self, provider: str, profile: str, model: str,
messages: list[dict[str, Any]], *,
tools: Optional[list[dict[str, Any]]] = None,
temperature: float = 0.8, max_tokens: int = 512,
thinking: bool = False,
response_format: str | dict[str, Any] = "",
timeout_seconds: float = 600.0,
vendor_options: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
if provider != "ollama":
raise ValueError("LLM provider must be ollama")
if (isinstance(temperature, bool)
or type(temperature) not in {int, float}
or not 0.0 <= float(temperature) <= 10.0):
raise ValueError("LLM temperature must be in [0, 10]")
if (isinstance(max_tokens, bool) or not isinstance(max_tokens, int)
or not 1 <= max_tokens <= 32_768):
raise ValueError("LLM max_tokens must be in [1, 32768]")
if type(thinking) is not bool:
raise TypeError("LLM thinking must be a bool")
keep_alive, keep_alive_unit = self._vendor_options(vendor_options)
response = await InProcessOllama().chat(
endpoint=profile,
model=model,
messages=self._messages(messages),
think=thinking,
options={
"temperature": float(temperature),
"num_predict": max_tokens,
},
keep_alive=keep_alive,
keep_alive_unit=keep_alive_unit,
format=response_format,
timeout_seconds=timeout_seconds,
tools=tools,
)
result: dict[str, Any] = {
"content": response["response"],
"tool_calls": response.get("tool_calls", []),
}
if "thinking" in response:
result["thinking"] = response["thinking"]
return result
@dataclass(frozen=True)
class _InProcessIntegrations:
anima: Any = field(default_factory=InProcessAnima)
civitai: Any = field(default_factory=InProcessCivitai)
llm: Any = field(default_factory=_InProcessLlm)
llama_cpp: Any = field(default_factory=InProcessLlamaCpp)
ollama: Any = field(default_factory=InProcessOllama)
wanvideo: Any = field(default_factory=_InProcessWanVideo)
web: Any = field(default_factory=lambda: _StubDomain("integrations.web"))
@dataclass
class InProcessContext:
assets: Any
progress: Any
scratch: Any
events: Any
storage: Any
capture: Any
ui: Any
output: Any
graph: Any
execution: Any
integrations: Any
models: Any
profiling: Any
preview_override: Any
system: Any
closures: Any
interact: Any
sample: Any
unsample: Any
serve: Any
secrets: Any
net: Any
class InProcessCtxProvider:
def build(self, plan: ExecutionPlan) -> Context:
return InProcessContext( # type: ignore[return-value]
assets=_InProcessAssets(),
progress=_InProcessProgress(plan.node_id),
scratch=_InProcessScratch(),
events=_InProcessEvents(),
storage=_StubDomain("storage"),
capture=_InProcessCapture(),
ui=_InProcessUi(plan.prompt, plan.extra_pnginfo),
output=_InProcessOutput(plan.prompt, plan.extra_pnginfo),
graph=_InProcessGraph(
plan.node_id, plan.prompt, plan.extra_pnginfo,
plan.dynamic_prompt),
execution=_InProcessExecution(plan.prompt_id),
integrations=_InProcessIntegrations(),
models=_InProcessModels(),
profiling=InProcessProfiling(
f"in-process:{plan.node_module}", plan.node_id),
preview_override=InProcessPreviewOverride(plan.node_id),
system=_InProcessSystem(),
closures=_InProcessClosures(),
interact=_StubDomain("interact"),
sample=_StubDomain("sample"),
unsample=_StubDomain("unsample"),
serve=_StubDomain("serve"),
secrets=_StubDomain("secrets"),
net=_StubDomain("net"),
)
class OpNotSupported(NotImplementedError):
"""Raised by ``apply`` for an op this provider does not implement. Carries
the capability name so a node can decide to fall back (e.g. to ``raw``)."""
def __init__(self, op: str) -> None:
self.op = op
self.capability = f"ops.{op}"
super().__init__(
f"op {op!r} is not supported by this ops provider "
f"(capability: {self.capability})"
)
@dataclass
class _WeightDiffCursorState:
patcher: Any
keys: list[tuple[str, str, str]]
index: int = 0
current_ref: Optional[TensorRef] = None
closed: bool = False
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
def _sample_self_refine_video(
model, x, sigmas, stochastic_step_map, certain_percentage=0.999,
uncertainty_threshold=0.25, extra_args=None, callback=None, disable=None,
verbose=False, video_shape=None, seed=None,
):
import math
import torch
from comfy.k_diffusion.sampling import to_d
from tqdm import tqdm
extra_args = {} if extra_args is None else extra_args
sigma_in = x.new_ones([x.shape[0]])
if seed is not None:
generator = torch.Generator(torch.device("cpu")).manual_seed(seed)
pbar = tqdm(total=len(sigmas) - 1, disable=disable, desc="Sampling")
for i in range(len(sigmas) - 1):
current_num_anneal_steps = stochastic_step_map.get(i, 0)
use_stochastic = current_num_anneal_steps > 0
m = current_num_anneal_steps + 1 if use_stochastic else 1
sigma, sigma_next = sigmas[i], sigmas[i + 1]
prev_certain_mask = None
prev_denoised = None
prev_denoised_full = None
prev_x_next = None
prev_x_next_video = None
is_certain = False
for ii in range(m):
if m > 1:
pbar.set_description(
f"Step {i}/{len(sigmas)-1} (substep {ii+1}/{m})")
if is_certain:
x = prev_x_next
break
noise = torch.randn(
x.shape, device=torch.device("cpu"), generator=generator).to(x)
x_in = (
x if ii == 0
else (1.0 - sigma) * prev_denoised_full + sigma * noise)
if ii > 0:
x = x_in
denoised = model(x_in, sigmas[i] * sigma_in, **extra_args)
if callback is not None:
callback({
"x": x,
"i": i,
"sigma": sigmas[i],
"sigma_hat": sigmas[i],
"denoised": denoised,
})
d = to_d(x, sigma, denoised)
x_next = x + (sigma_next - sigma) * d
if d.ndim == 3 and video_shape is not None:
cut = math.prod(video_shape[1:])
denoised_video = denoised[:, :, :cut].reshape(
[denoised.shape[0]] + list(video_shape)[1:])
x_next_video = x_next[:, :, :cut].reshape(
[denoised.shape[0]] + list(video_shape)[1:])
denoised_audio = denoised[:, :, cut:]
x_next_audio = x_next[:, :, cut:]
if verbose:
tqdm.write(
f"Video shape: {denoised_video.shape}, "
f"Audio shape: {denoised_audio.shape}")
else:
denoised_video = denoised
x_next_video = x_next
denoised_audio = None
x_next_audio = None
if use_stochastic and prev_denoised is not None:
diff = denoised_video - prev_denoised
uncertainty = (
torch.sqrt(torch.sum(diff ** 2, dim=1))
/ denoised_video.shape[1])
certain_mask = uncertainty < uncertainty_threshold
if verbose:
tqdm.write(
f"Step {i}/{len(sigmas)-1} substep {ii+1}/{m}:")
tqdm.write(
f"Uncertainty: min {uncertainty.min():.4f}, "
f"max {uncertainty.max():.4f}, "
f"threshold {uncertainty_threshold}")
tqdm.write(
f"Certain pixels: {certain_mask.sum()}/"
f"{certain_mask.numel()} = "
f"{certain_mask.sum()/certain_mask.numel():.4f}")
if prev_certain_mask is not None:
certain_mask = certain_mask | prev_certain_mask
if certain_mask.sum() / certain_mask.numel() > (
certain_percentage):
is_certain = True
if verbose:
tqdm.write(
f"{ii}/{current_num_anneal_steps}: Certain region "
f"is more than {certain_percentage}, we are certain")
certain_mask_float = certain_mask.float().unsqueeze(1)
x_next_video = (
certain_mask_float * prev_x_next_video
+ (1.0 - certain_mask_float) * x_next_video)
denoised_video = (
certain_mask_float * prev_denoised
+ (1.0 - certain_mask_float) * denoised_video)
if x_next_audio is not None:
x_next = x_next.clone()
x_next[:, :, :cut] = x_next_video.reshape(
[x_next_video.shape[0], x_next.shape[1], -1])
denoised_full = denoised.clone()
denoised_full[:, :, :cut] = denoised_video.reshape(
[denoised_video.shape[0], denoised.shape[1], -1])
else:
x_next = x_next_video
denoised_full = denoised_video
prev_certain_mask = certain_mask
prev_denoised = denoised_video
prev_denoised_full = denoised_full
prev_x_next_video = x_next_video
prev_x_next = x_next
elif use_stochastic:
if x_next_audio is not None:
denoised_full = denoised.clone()
denoised_full[:, :, :cut] = denoised_video.reshape(
[denoised_video.shape[0], denoised.shape[1], -1])
else:
denoised_full = denoised_video
prev_certain_mask = None
prev_denoised = denoised_video
prev_denoised_full = denoised_full
prev_x_next_video = x_next_video
prev_x_next = x_next
if use_stochastic and ii == m - 1:
x = prev_x_next
elif not use_stochastic:
x = x_next
pbar.update(1)
if m == 1:
pbar.set_description("Sampling")
pbar.close()
return x
class InProcessOps:
"""Default operations. Runs in the trusted process; uses the real buffers
via the resolver but never hands them to the node. Kept torch-free (tensor
arithmetic works on the resolved objects directly). The op set is a
registry: core ships a closed primitive set; ``register_op`` extends it
(an overlay subclasses or registers richer ops)."""
def __init__(self) -> None:
self._ops: dict[str, Callable[..., Awaitable["ImageRef"]]] = {
"ref.describe": self._ref_describe,
"interpolation_states.skip_mask":
self._interpolation_states_skip_mask,
"invert": self._invert,
"scale": self._scale,
"image.rgb": self._image_rgb,
"image.to_device": self._image_to_device,
"image.spatial_shape": self._image_spatial_shape,
"image.batch_size": self._image_batch_size,
"image.select_batch": self._image_select_batch,
"mask.grow": self._mask_grow,
# Operations on live engine objects. These are what let a node
# DECLARE a MODEL/CLIP/VAE input and still be sandboxable: the node
# names the operation, the weights stay here.
"vae.decode": self._vae_decode,
"vae.latent_layout": self._vae_latent_layout,
"vae.decode_tensor": self._vae_decode_tensor,
"vae.decode_tiled": self._vae_decode_tiled,
"vae.decode_tensor_tiled": self._vae_decode_tensor_tiled,
"vae.encode": self._vae_encode,
"vae.encode_for_inpaint": self._vae_encode_for_inpaint,
"vae.encode_inpaint_conditioning":
self._vae_encode_inpaint_conditioning,
"vae.encode_tiled": self._vae_encode_tiled,
"vae.input_dtype": self._vae_input_dtype,
"vae.encode_video": self._vae_encode_video,
"vae.decode_video": self._vae_decode_video,
"vae.decode_audio": self._vae_decode_audio,
"vae.downscale_index_formula": self._vae_downscale_index_formula,
"vae.merge": self._vae_merge,
"vae.compile": self._vae_compile,
"vae.patch_triton": self._vae_patch_triton,
"video.encoded_source": self._video_encoded_source,
"clip.tokenize": self._clip_tokenize,
"clip.encode_from_tokens_scheduled":
self._clip_encode_from_tokens_scheduled,
"clip.encode_token_weights_component":
self._clip_encode_token_weights_component,
"clip.encode": self._clip_encode,
"clip.set_last_layer": self._clip_set_last_layer,
"clip.with_attention_impl": self._clip_with_attention_impl,
"clip.describe_tokens": self._clip_describe_tokens,
"clip.generate_text": self._clip_generate_text,
"gligen.apply_batched": self._gligen_apply_batched,
"latent.noise_mask": self._latent_noise_mask,
"latent.repeat_batch": self._latent_repeat_batch,
"latent.minimax_h3_token_count":
self._latent_minimax_h3_token_count,
"latent.empty": self._latent_empty,
"sigmas.steps": self._sigmas_steps,
"sigmas.value_at": self._sigmas_value_at,
"sampler.named": self._sampler_named,
"cond.sequence_length": self._cond_sequence_length,
"cond.combine": self._cond_combine,
"cond.concat": self._cond_concat,
"cond.zero_out": self._cond_zero_out,
"cond.with_timestep_range": self._cond_with_timestep_range,
"cond.with_metadata": self._cond_with_metadata,
"cond.has_spatial_metadata": self._cond_has_spatial_metadata,
"cond.with_mask": self._cond_with_mask,
"cond.with_clip_vision_output":
self._cond_with_clip_vision_output,
"cond.with_concat_latent": self._cond_with_concat_latent,
"cond.spatial_crop": self._cond_spatial_crop,
"latent.spatial_shape": self._latent_spatial_shape,
"latent.resize": self._latent_resize,
"latent.random_noise": self._latent_random_noise,
"latent.composite": self._latent_composite,
"clip.scale_attention_weights": self._clip_scale_attention_weights,
"advanced_control.weights_from_list":
self._advanced_control_weights_from_list,
"advanced_control.scaled_soft_weights":
self._advanced_control_scaled_soft_weights,
"lora.weight_differences": self._lora_weight_differences,
"weight_diff.next": self._weight_diff_next,
"model.apply_lora": self._model_apply_lora,
"model.apply_dit_block_lora": self._model_apply_dit_block_lora,
"model.apply_ltx2_lora": self._model_apply_ltx2_lora,
"sampling.spatial_crop_inputs":
self._sampling_spatial_crop_inputs,
"model.patch": self._model_patch,
"model.ground_image": self._model_ground_image,
"model.transforms": self._model_transforms,
"model.is_flow": self._model_is_flow,
"model.family": self._model_family,
"model.unet_context_dim": self._model_unet_context_dim,
"model.is_zero_terminal_snr": self._model_is_zero_terminal_snr,
"model.sigma_for_percent": self._model_sigma_for_percent,
"model.sampling_sigma_delta": self._model_sampling_sigma_delta,
"model.latent_scale_factor": self._model_latent_scale_factor,
"guider.scheduled_cfg": self._guider_scheduled_cfg,
"sampler.self_refine_video": self._sampler_self_refine_video,
"clip_vision.encode_image": self._clip_vision_encode_image,
"clip_vision_output.image_embeds":
self._clip_vision_output_image_embeds,
"clip_vision_output.concat": self._clip_vision_output_concat,
"controlnet.with_union_type": self._controlnet_with_union_type,
"controlnet.apply": self._controlnet_apply,
"controlnet.apply_advanced": self._controlnet_apply_advanced,
"controlnet.compile": self._controlnet_compile,
"style_model.apply": self._style_model_apply,
"clipseg.predict_mask": self._clipseg_predict_mask,
"clipseg.segment": self._clipseg_segment,
"image_classifier.classify": self._image_classifier_classify,
"image_classifier.predict_scores":
self._image_classifier_predict_scores,
"classifier_scores.shape": self._classifier_scores_shape,
"classifier_scores.select_above":
self._classifier_scores_select_above,
"semantic_segmentation.mask": self._semantic_segmentation_mask,
"matting.refine": self._matting_refine,
"vqa.answer": self._vqa_answer,
"onnx_detector.detect": self._onnx_detector_detect,
"object_detector.detect": self._object_detector_detect,
"inpaint_model.inpaint": self._inpaint_model_inpaint,
"background_removal.mask": self._background_removal_mask,
"brushnet.apply": self._brushnet_apply,
"powerpaint.apply": self._powerpaint_apply,
"transparent_vae_decoder.decode":
self._transparent_vae_decoder_decode,
"image_preprocessor.apply": self._image_preprocessor_apply,
"ipadapter.apply": self._ipadapter_apply,
"ipadapter.apply_tiled": self._ipadapter_apply_tiled,
"ipadapter.encode": self._ipadapter_encode,
"ipadapter.apply_embeds": self._ipadapter_apply_embeds,
"ipadapter_embeds.combine": self._ipadapter_embeds_combine,
"sam.segment": self._sam_segment,
"sam.segment_video": self._sam_segment_video,
"upscale_model.upscale": self._upscale_model_upscale,
}
def register_op(self, name: str, fn: Callable[..., Awaitable["ImageRef"]]) -> None:
self._ops[name] = fn
def supports(self, op: str) -> bool:
return op in self._ops
async def apply(self, op: str, subject: "Ref", params: dict) -> Any:
fn = self._ops.get(op)
if fn is None:
raise OpNotSupported(op)
return await fn(subject, **params)
async def _ref_describe(
self, ref: "Ref", max_value_chars: int = 32768,
) -> dict[str, Any]:
"""Project safe diagnostics without invoking behavior on the value."""
if (isinstance(max_value_chars, bool)
or not isinstance(max_value_chars, int)):
raise TypeError("ref description max_value_chars must be an integer")
if not 32 <= max_value_chars <= 32768:
raise ValueError(
"ref description max_value_chars must be in [32, 32768]")
value = await current_runtime().refs.resolve(ref)
kind = ref.kind[:128] if isinstance(ref.kind, str) else "UNKNOWN"
shape: list[int] | None = None
length: int | None = None
first: str | None = None
summary: str | None = None
type_name = f"opaque {kind}"
# Only exact, trusted structural cases are inspected. In particular,
# do not use hasattr(), len(), iter(), str(), or repr() on an arbitrary
# host object: each can execute pack- or vendor-defined Python.
import torch
if isinstance(value, torch.Tensor):
shape = [int(item) for item in value.shape]
if shape:
length = shape[0]
first_shape = shape[1:]
first = f"<redacted tensor slice shape={first_shape}>"
type_name = "Tensor"
summary = (
f"<{kind} tensor shape={shape} dtype={value.dtype} "
f"device={value.device.type}>"
)
elif ref.kind == "LATENT" and type(value) is dict:
type_name = "Latent"
length = len(value)
first = "<latent field>" if value else None
samples = value.get("samples")
if isinstance(samples, torch.Tensor):
shape = [int(item) for item in samples.shape]
summary = f"<LATENT fields={length} shape={shape}>"
elif ref.kind == "CONDITIONING" and type(value) is list:
type_name = "Conditioning"
length = len(value)
first = "<conditioning row>" if value else None
summary = f"<CONDITIONING rows={length}>"
elif ref.kind == "VALUE":
exact = type(value)
if exact in {list, tuple, dict, str, bytes}:
type_name = exact.__name__
length = len(value)
first = "<redacted item>" if length else None
summary = f"<{type_name} length={length}>"
elif value is None or exact in {bool, int, float}:
type_name = "NoneType" if value is None else exact.__name__
summary = repr(value)
else:
type_name = "opaque VALUE"
summary = "<opaque VALUE>"
else:
summary = f"<opaque {kind}>"
truncated = len(summary) > max_value_chars
if truncated:
summary = summary[:max_value_chars - 1] + "…"
return {
"kind": kind,
"type": type_name,
"length": length,
"first": first,
"shape": shape,
"summary": summary,
"truncated": truncated,
}
async def _interpolation_states_skip_mask(
self, states: "InterpolationStatesRef", pair_count: int,
) -> list[bool]:
"""Project a foreign interpolation policy without invoking it.
ComfyUI-Frame-Interpolation represents this value as a small object
with two instance-data fields. Treat those fields as data only: an
arbitrary method, property, iterator, or string conversion on the
foreign object must never execute in the trusted process.
"""
if isinstance(pair_count, bool) or not isinstance(pair_count, int):
raise TypeError("interpolation pair_count must be an integer")
if not 1 <= pair_count <= 100_000:
raise ValueError("interpolation pair_count must be in [1, 100000]")
value = await current_runtime().refs.resolve(states)
try:
fields = object.__getattribute__(value, "__dict__")
except (AttributeError, TypeError) as error:
raise TypeError(
"INTERPOLATION_STATES must expose fixed instance data"
) from error
if type(fields) is not dict:
raise TypeError(
"INTERPOLATION_STATES must expose fixed instance data")
if set(fields) != {"frame_indices", "is_skip_list"}:
raise TypeError(
"INTERPOLATION_STATES has an unsupported field layout")
indices = fields["frame_indices"]
skip_list = fields["is_skip_list"]
if type(indices) is not list:
raise TypeError("interpolation frame_indices must be a list")
if len(indices) > 100_000:
raise ValueError(
"interpolation frame_indices exceeds the 100000 item limit")
if type(skip_list) is not bool:
raise TypeError("interpolation is_skip_list must be a boolean")
selected: set[int] = set()
for index in indices:
if isinstance(index, bool) or not isinstance(index, int):
raise TypeError(
"interpolation frame indices must be integers")
if index < 0:
raise ValueError(
"interpolation frame indices must be non-negative")
if index < pair_count:
selected.add(index)
if skip_list:
return [index in selected for index in range(pair_count)]
return [index not in selected for index in range(pair_count)]
async def _invert(self, image: "ImageRef") -> "ImageRef":
t = await current_runtime().refs.resolve(image)
return ImageRef._wrap(await current_runtime().refs.create("IMAGE", 1.0 - t)) # type: ignore[return-value]
async def _scale(self, image: "ImageRef", factor: float) -> "ImageRef":
t = await current_runtime().refs.resolve(image)
return ImageRef._wrap(await current_runtime().refs.create("IMAGE", t * factor)) # type: ignore[return-value]
async def _image_rgb(self, image: "ImageRef") -> "ImageRef":
import torch
rt = current_runtime()
value = await rt.refs.resolve(image)
if not isinstance(value, torch.Tensor) or value.ndim < 3:
raise TypeError("IMAGE must contain a channel-last tensor")
if value.shape[-1] < 3:
raise ValueError("IMAGE must contain at least three channels")
return ImageRef._wrap(await rt.refs.create(
"IMAGE", value[..., :3])) # type: ignore[return-value]
async def _image_to_device(
self, image: "ImageRef", device: str = "auto",
) -> "ImageRef":
import torch
import comfy.model_management
choices = {
"auto": comfy.model_management.intermediate_device,
"gpu": comfy.model_management.get_torch_device,
"cpu": lambda: torch.device("cpu"),
}
if device not in choices:
raise ValueError("image device must be auto, cpu, or gpu")
rt = current_runtime()
value = await rt.refs.resolve(image)
result = value.clone().to(choices[device]())
torch.cuda.empty_cache()
return ImageRef._wrap(
await rt.refs.create("IMAGE", result)
) # type: ignore[return-value]
async def _image_spatial_shape(
self, image: "ImageRef",
) -> tuple[int, int]:
import torch
value = await current_runtime().refs.resolve(image)
if not isinstance(value, torch.Tensor) or value.ndim not in (3, 4):
raise TypeError("IMAGE must contain an HWC or BHWC tensor")
if value.shape[-1] not in (1, 3, 4):
raise ValueError("IMAGE must have 1, 3, or 4 channels")
return int(value.shape[-3]), int(value.shape[-2])
async def _image_batch_size(self, image: "ImageRef") -> int:
import torch
value = await current_runtime().refs.resolve(image)
if not isinstance(value, torch.Tensor) or value.ndim not in (3, 4):
raise TypeError("IMAGE must contain an HWC or BHWC tensor")
if value.shape[-1] not in (1, 3, 4):
raise ValueError("IMAGE must have 1, 3, or 4 channels")
batch = 1 if value.ndim == 3 else int(value.shape[0])
if not 1 <= batch <= 4096:
raise ValueError("IMAGE batch size must be in [1, 4096]")
return batch
async def _image_select_batch(
self, image: "ImageRef", indices: list[int],
) -> "ImageRef":
import torch
if (
not isinstance(indices, list)
or not 1 <= len(indices) <= 4096
or any(isinstance(index, bool) or not isinstance(index, int)
for index in indices)
or len(set(indices)) != len(indices)
):
raise ValueError(
"image batch indices must be 1..4096 unique integers")
rt = current_runtime()
value = await rt.refs.resolve(image)
if (
not isinstance(value, torch.Tensor)
or value.ndim != 4
or value.shape[-1] not in (1, 3, 4)
or not 1 <= int(value.shape[0]) <= 4096
):
raise TypeError("image batch selection requires a BHWC IMAGE")
if min(indices) < 0 or max(indices) >= int(value.shape[0]):
raise IndexError("image batch index is out of range")
selected = value[indices]
if selected.numel() > 268_435_456:
raise ValueError("selected image batch is too large")
return ImageRef._wrap(await rt.refs.create(
"IMAGE", selected)) # type: ignore[return-value]
async def _mask_grow(
self, mask: "MaskRef", amount: int,
tapered_corners: bool = False,
) -> "MaskRef":
import torch
from comfy_extras.nodes_mask import GrowMask
if isinstance(amount, bool) or not isinstance(amount, int):
raise TypeError("mask grow amount must be an integer")
if not -512 <= amount <= 512:
raise ValueError("mask grow amount must be in [-512, 512]")
if type(tapered_corners) is not bool:
raise TypeError("mask tapered_corners must be a bool")
rt = current_runtime()
value = await rt.refs.resolve(mask)
if not isinstance(value, torch.Tensor) or value.ndim < 2:
raise TypeError("MASK must contain a tensor with spatial axes")
result = GrowMask.execute(
value.detach().cpu(), amount, tapered_corners).result[0]
return MaskRef._wrap(await rt.refs.create(
"MASK", result)) # type: ignore[return-value]
# --- operations on live engine objects ------------------------------- #
# Each resolves its handles to the real objects HERE, on the trusted plane,
# runs core's own semantics, and returns a handle. A guest never holds the
# model; it holds the name of what it wanted done.
@staticmethod
def _ensure_vae_current_defaults(value: Any) -> None:
"""Normalize legacy VAE objects at the trusted API boundary.
Some external VAE loaders copied an older ComfyUI initializer instead
of calling the current base initializer. Current encode/decode code
legitimately expects these two inert attributes. Supplying their
canonical defaults only when absent keeps every VAE operation usable
without exposing or replacing the external loader's implementation.
"""
for name, default in (
("handles_tiling", False),
("format_encoded", None),
):
if not hasattr(value, name):
setattr(value, name, default)
@staticmethod
def _normalize_decoded_tensor(value: Any) -> Any:
import torch
if not isinstance(value, torch.Tensor):
raise TypeError("VAE decode must return a tensor")
if value.ndim == 5:
value = value.reshape(
-1, value.shape[-3], value.shape[-2], value.shape[-1])
if value.ndim != 4:
raise TypeError("VAE tensor decode must return BHWC or BTHWC")
if any(int(size) < 1 for size in value.shape):
raise ValueError("VAE tensor decode returned an empty dimension")
if int(value.shape[-1]) > 4096:
raise ValueError("VAE tensor decode has too many channels")
return value
@staticmethod
def _validate_vae_decode_tiles(
tile_size: int, overlap: int, temporal_size: int,
temporal_overlap: int,
) -> None:
if not 64 <= tile_size <= 4096:
raise ValueError("VAE decode tile_size must be in [64, 4096]")
if not 0 <= overlap <= 4096:
raise ValueError("VAE decode overlap must be in [0, 4096]")
if not 8 <= temporal_size <= 4096:
raise ValueError(
"VAE decode temporal_size must be in [8, 4096]")
if not 4 <= temporal_overlap <= 4096:
raise ValueError(
"VAE decode temporal_overlap must be in [4, 4096]")
async def _vae_decode(self, vae: "VaeRef", latent: "LatentRef") -> "ImageRef":
rt = current_runtime()
v = await rt.refs.resolve(vae)
self._ensure_vae_current_defaults(v)
samples = await rt.refs.resolve(latent)
return ImageRef._wrap(await rt.refs.create("IMAGE", v.decode(samples["samples"]))) # type: ignore[return-value]
async def _vae_latent_layout(
self, vae: "VaeRef",
) -> dict[str, Optional[int]]:
value = await current_runtime().refs.resolve(vae)
channels = getattr(value, "latent_channels", None)
spatial_fn = getattr(value, "spacial_compression_encode", None)
if (
isinstance(channels, bool)
or not isinstance(channels, int)
or not 1 <= channels <= 4096
or not callable(spatial_fn)
):
raise ValueError(
"VAE does not publish a bounded latent layout")
spatial = spatial_fn()
if (
isinstance(spatial, bool)
or not isinstance(spatial, int)
or not 1 <= spatial <= 256
):
raise ValueError(
"VAE spatial compression must be an integer in [1, 256]")
temporal = None
temporal_fn = getattr(value, "temporal_compression_encode", None)
if callable(temporal_fn):
temporal = temporal_fn()
if (
isinstance(temporal, bool)
or not isinstance(temporal, int)
or not 1 <= temporal <= 256
):
raise ValueError(
"VAE temporal compression must be an integer in [1, 256]")
return {
"channels": channels,
"spatial_compression": spatial,
"temporal_compression": temporal,
}
async def _vae_decode_tensor(
self, vae: "VaeRef", latent: "LatentRef",
) -> "TensorRef":
rt = current_runtime()
value = await rt.refs.resolve(vae)
self._ensure_vae_current_defaults(value)
latent_value = await rt.refs.resolve(latent)
decoded = self._normalize_decoded_tensor(
value.decode(latent_value["samples"]))
return TensorRef._wrap(await rt.refs.create(
"TENSOR", decoded)) # type: ignore[return-value]
async def _vae_decode_tiled(
self, vae: "VaeRef", latent: "LatentRef", tile_size: int = 512,
overlap: int = 64, temporal_size: int = 64,
temporal_overlap: int = 8,
) -> "ImageRef":
from nodes import VAEDecodeTiled
self._validate_vae_decode_tiles(
tile_size, overlap, temporal_size, temporal_overlap)
rt = current_runtime()
value = await rt.refs.resolve(vae)
self._ensure_vae_current_defaults(value)
samples = await rt.refs.resolve(latent)
pixels = VAEDecodeTiled().decode(
value, samples, tile_size, overlap, temporal_size,
temporal_overlap)[0]
return ImageRef._wrap(await rt.refs.create(
"IMAGE", pixels)) # type: ignore[return-value]
async def _vae_decode_tensor_tiled(
self, vae: "VaeRef", latent: "LatentRef", tile_size: int = 512,
overlap: int = 64, temporal_size: int = 64,
temporal_overlap: int = 8,
) -> "TensorRef":
from nodes import VAEDecodeTiled
self._validate_vae_decode_tiles(
tile_size, overlap, temporal_size, temporal_overlap)
rt = current_runtime()
value = await rt.refs.resolve(vae)
self._ensure_vae_current_defaults(value)
samples = await rt.refs.resolve(latent)
decoded = VAEDecodeTiled().decode(
value, samples, tile_size, overlap, temporal_size,
temporal_overlap)[0]
decoded = self._normalize_decoded_tensor(decoded)
return TensorRef._wrap(await rt.refs.create(
"TENSOR", decoded)) # type: ignore[return-value]
async def _vae_encode(self, vae: "VaeRef", image: "ImageRef") -> "LatentRef":
rt = current_runtime()
v = await rt.refs.resolve(vae)
self._ensure_vae_current_defaults(v)
pixels = await rt.refs.resolve(image)
return LatentRef._wrap(await rt.refs.create( # type: ignore[return-value]
"LATENT", {"samples": v.encode(pixels)}))
async def _vae_encode_for_inpaint(
self, vae: "VaeRef", image: "ImageRef", mask: "MaskRef",
grow_mask_by: int = 6,
) -> "LatentRef":
from nodes import VAEEncodeForInpaint
if (
isinstance(grow_mask_by, bool)
or not isinstance(grow_mask_by, int)
or not 0 <= grow_mask_by <= 64
):
raise ValueError("inpaint mask growth must be an integer in [0, 64]")
rt = current_runtime()
vae_value = await rt.refs.resolve(vae)
self._ensure_vae_current_defaults(vae_value)
pixels = await rt.refs.resolve(image)
mask_value = await rt.refs.resolve(mask)
result = VAEEncodeForInpaint().encode(
vae_value, pixels, mask_value, grow_mask_by)[0]
return LatentRef._wrap(await rt.refs.create(
"LATENT", result)) # type: ignore[return-value]
async def _vae_encode_inpaint_conditioning(
self, vae: "VaeRef", image: "ImageRef", mask: "MaskRef",
positive: "CondRef", negative: "CondRef",
noise_mask: bool = True,
) -> tuple["CondRef", "CondRef", "LatentRef"]:
from nodes import InpaintModelConditioning
if type(noise_mask) is not bool:
raise TypeError("inpaint noise_mask must be a bool")
rt = current_runtime()
values = await asyncio.gather(
rt.refs.resolve(positive),
rt.refs.resolve(negative),
rt.refs.resolve(image),
rt.refs.resolve(vae),
rt.refs.resolve(mask),
)
self._ensure_vae_current_defaults(values[3])
result = InpaintModelConditioning().encode(
values[0], values[1], values[2], values[3], values[4],
noise_mask)
return (
CondRef._wrap(await rt.refs.create("CONDITIONING", result[0])),
CondRef._wrap(await rt.refs.create("CONDITIONING", result[1])),
LatentRef._wrap(await rt.refs.create("LATENT", result[2])),
) # type: ignore[return-value]
async def _vae_encode_tiled(
self, vae: "VaeRef", image: "ImageRef", tile_x=None, tile_y=None,
overlap=None, tile_t=None, overlap_t=None,
) -> "LatentRef":
rt = current_runtime()
value = await rt.refs.resolve(vae)
self._ensure_vae_current_defaults(value)
pixels = await rt.refs.resolve(image)
kwargs = {
key: item for key, item in {
"tile_x": tile_x,
"tile_y": tile_y,
"overlap": overlap,
"tile_t": tile_t,
"overlap_t": overlap_t,
}.items() if item is not None
}
samples = value.encode_tiled(pixels, **kwargs)
return LatentRef._wrap(await rt.refs.create(
"LATENT", {"samples": samples})) # type: ignore[return-value]
async def _vae_input_dtype(self, vae: "VaeRef") -> str:
value = await current_runtime().refs.resolve(vae)
name = str(value.vae_dtype).removeprefix("torch.")
if name not in {"float16", "bfloat16", "float32", "float64"}:
raise TypeError(f"unsupported VAE input dtype {name!r}")
return name
async def _vae_encode_video(
self, vae: "VaeRef", image: "ImageRef",
) -> tuple["LatentRef", int]:
import torch
rt = current_runtime()
value = await rt.refs.resolve(vae)
self._ensure_vae_current_defaults(value)
pixels = await rt.refs.resolve(image)
if not isinstance(pixels, torch.Tensor) or pixels.ndim != 4:
raise TypeError("VAE video encode needs BHWC image frames")
pixels = pixels.to(dtype=value.vae_dtype, device="cpu")
try:
temporal_compress = value.downscale_ratio[0]
temporal_decompress = value.upscale_ratio[0]
valid_frames = temporal_decompress(
temporal_compress(pixels.shape[0]))
if valid_frames < pixels.shape[0]:
pixels = pixels[:valid_frames]
except (TypeError, IndexError):
pass
samples = value.encode(pixels)
latent = LatentRef._wrap(await rt.refs.create(
"LATENT", {"samples": samples}))
return latent, int(pixels.shape[0])
async def _vae_decode_video(
self, vae: "VaeRef", latent: "LatentRef", tiled: bool = False,
tile_size: int = 512, overlap: int = 64,
temporal_size: int = 4096, temporal_overlap: int = 16,
) -> "ImageRef":
import torch
rt = current_runtime()
value = await rt.refs.resolve(vae)
self._ensure_vae_current_defaults(value)
latent_value = await rt.refs.resolve(latent)
if not isinstance(latent_value, dict) or "samples" not in latent_value:
raise TypeError("VAE video decode needs a LATENT with samples")
samples = latent_value["samples"]
if getattr(samples, "is_nested", False):
samples = samples.unbind()[0]
if tiled:
tile_size = int(tile_size)
overlap = int(overlap)
temporal_size = int(temporal_size)
temporal_overlap = int(temporal_overlap)
if not 64 <= tile_size <= 4096:
raise ValueError("video VAE tile_size must be in [64, 4096]")
if not 0 <= overlap <= 4096:
raise ValueError("video VAE overlap must be in [0, 4096]")
if not 8 <= temporal_size <= 4096:
raise ValueError(
"video VAE temporal_size must be in [8, 4096]")
if not 4 <= temporal_overlap <= 4096:
raise ValueError(
"video VAE temporal_overlap must be in [4, 4096]")
if tile_size < overlap * 4:
overlap = tile_size // 4
if temporal_size < temporal_overlap * 2:
temporal_overlap //= 2
temporal_compression = value.temporal_compression_decode()
if temporal_compression is None:
tile_t = None
overlap_t = None
else:
tile_t = max(2, temporal_size // temporal_compression)
overlap_t = max(
1, min(tile_t // 2,
temporal_overlap // temporal_compression))
spatial_compression = value.spacial_compression_decode()
tile_x = max(1, tile_size // spatial_compression)
spatial_overlap = max(1, overlap // spatial_compression)
images = value.decode_tiled(
samples, tile_t=tile_t, tile_x=tile_x, tile_y=tile_x,
overlap=spatial_overlap, overlap_t=overlap_t)
else:
images = value.decode(samples)
if not isinstance(images, torch.Tensor):
raise TypeError("VAE video decode did not return a tensor")
if images.ndim == 5:
images = images.reshape(
-1, images.shape[-3], images.shape[-2], images.shape[-1])
return ImageRef._wrap(await rt.refs.create("IMAGE", images)) # type: ignore[return-value]
async def _vae_decode_audio(
self, vae: "VaeRef", latent: "LatentRef",
) -> "AudioRef":
rt = current_runtime()
value = await rt.refs.resolve(vae)
self._ensure_vae_current_defaults(value)
latent_value = await rt.refs.resolve(latent)
if not isinstance(latent_value, dict) or "samples" not in latent_value:
raise TypeError("VAE audio decode needs a LATENT with samples")
samples = latent_value["samples"]
if getattr(samples, "is_nested", False):
samples = samples.unbind()[-1]
audio = value.decode(samples)
if hasattr(value, "first_stage_model"):
audio = audio.movedim(-1, 1)
audio = audio.to(samples.device)
sample_rate = getattr(
value, "audio_sample_rate_output",
getattr(value, "output_sample_rate", None))
if sample_rate is None:
sample_rate = getattr(
getattr(value, "first_stage_model", None),
"output_sample_rate", 44100)
return AudioRef._wrap(await rt.refs.create("AUDIO", {
"waveform": audio,
"sample_rate": int(sample_rate),
})) # type: ignore[return-value]
async def _video_encoded_source(
self, video: "VideoRef",
) -> "ValueRef":
import io
import torch
rt = current_runtime()
value = await rt.refs.resolve(video)
source = value.get_stream_source()
max_bytes = int(os.environ.get(
"COMFY_SECURE_VIDEO_SOURCE_MAX", str(1024 * 1024 * 1024)))
if isinstance(source, (str, os.PathLike)):
size = os.path.getsize(source)
if size > max_bytes:
raise ValueError(
f"encoded video exceeds the {max_bytes}-byte limit")
with open(source, "rb") as stream:
content = stream.read(max_bytes + 1)
elif isinstance(source, io.BytesIO):
content = source.getvalue()
elif hasattr(source, "read"):
if hasattr(source, "seek"):
source.seek(0)
content = source.read(max_bytes + 1)
else:
raise TypeError("VIDEO stream source is not readable")
if not isinstance(content, (bytes, bytearray, memoryview)):
raise TypeError("VIDEO stream source did not return bytes")
if len(content) > max_bytes:
raise ValueError(
f"encoded video exceeds the {max_bytes}-byte limit")
if hasattr(value, "get_active_trim_window"):
start_time, duration = value.get_active_trim_window()
else:
start_time, duration = 0.0, 0.0
data = torch.frombuffer(bytearray(content), dtype=torch.uint8)
result = {
"data": data,
"start_time": float(start_time),
"duration": float(duration),
}
return ValueRef._wrap(await rt.refs.create("VALUE", result)) # type: ignore[return-value]
async def _vae_downscale_index_formula(
self, vae: "VaeRef",
) -> Optional[tuple[int, int, int]]:
value = await current_runtime().refs.resolve(vae)
formula = value.downscale_index_formula
if formula is None:
return None
if (not isinstance(formula, (list, tuple)) or len(formula) != 3
or any(isinstance(item, bool) or not isinstance(item, int)
for item in formula)):
raise TypeError(
"VAE downscale_index_formula must be three integers or None")
return tuple(formula)
async def _vae_merge(
self, vae: "VaeRef", other: "VaeRef", ratio: float = 0.5,
) -> "VaeRef":
import math
import torch
from comfy.sd import VAE
value = float(ratio)
if not math.isfinite(value) or not 0.0 <= value <= 1.0:
raise ValueError("VAE merge ratio must be finite and in [0, 1]")
rt = current_runtime()
first = await rt.refs.resolve(vae)
second = await rt.refs.resolve(other)
first_sd = first.get_sd()
second_sd = second.get_sd()
mismatch = set(first_sd) ^ set(second_sd)
if mismatch:
raise ValueError(
"Cannot merge: VAE architectures differ "
f"({len(mismatch)} non-matching keys, e.g. {list(mismatch)[:3]}).")
merged = {}
for key, first_value in first_sd.items():
second_value = second_sd[key]
if first_value.shape != second_value.shape:
raise ValueError(
f"Cannot merge: shape mismatch for {key!r} "
f"({tuple(first_value.shape)} vs {tuple(second_value.shape)})")
if torch.is_floating_point(first_value):
blended = torch.lerp(
first_value.float(),
second_value.to(device=first_value.device).float(), value)
merged[key] = blended.to(dtype=first_value.dtype)
else:
merged[key] = first_value.clone()
result = VAE(
sd=merged, device=first.device, dtype=first.vae_dtype)
result.throw_exception_if_invalid()
return VaeRef._wrap(await rt.refs.create("VAE", result)) # type: ignore[return-value]
@staticmethod
def _checked_compile_options(
backend: str, mode: str, fullgraph: bool,
) -> dict[str, Any]:
if backend not in {"inductor", "cudagraphs"}:
raise ValueError(
"torch.compile backend must be inductor or cudagraphs")
if mode not in {
"default",
"max-autotune",
"max-autotune-no-cudagraphs",
"reduce-overhead",
}:
raise ValueError(f"unsupported torch.compile mode {mode!r}")
if not isinstance(fullgraph, bool):
raise TypeError("torch.compile fullgraph must be a bool")
return {
"backend": backend,
"mode": mode,
"fullgraph": fullgraph,
}
async def _vae_compile(
self, vae: "VaeRef", backend: str = "inductor",
mode: str = "default", fullgraph: bool = False,
encoder: bool = True, decoder: bool = True,
) -> "VaeRef":
import copy
import torch
if not isinstance(encoder, bool) or not isinstance(decoder, bool):
raise TypeError("VAE compile encoder and decoder flags must be bools")
options = self._checked_compile_options(backend, mode, fullgraph)
if not encoder and not decoder:
return vae
rt = current_runtime()
source = await rt.refs.resolve(vae)
stage_source = getattr(source, "first_stage_model", None)
patcher_source = getattr(source, "patcher", None)
if stage_source is None or patcher_source is None:
raise TypeError("VAE compile needs a valid first-stage model")
result = copy.copy(source)
stage = copy.copy(stage_source)
stage._modules = stage_source._modules.copy()
result.first_stage_model = stage
result.patcher = patcher_source.clone()
targets = []
if encoder:
targets.append(
"taesd_encoder" if hasattr(stage, "taesd_encoder")
else "encoder")
if decoder:
targets.append(
"taesd_decoder" if hasattr(stage, "taesd_decoder")
else "decoder")
for name in targets:
module = getattr(stage, name, None)
if module is None:
raise TypeError(f"VAE has no compilable {name}")
setattr(stage, name, torch.compile(module, **options))
return VaeRef._wrap(await rt.refs.create(
"VAE", result)) # type: ignore[return-value]
async def _vae_patch_triton(
self, vae: "VaeRef", fuse_norm_silu: bool = True,
channels_last: bool = True, int8_conv: bool = False,
autotune: bool = False,
) -> "VaeRef":
import torch
options = {
"fuse_norm_silu": fuse_norm_silu,
"channels_last": channels_last,
"int8_conv": int8_conv,
"autotune": autotune,
}
if any(type(value) is not bool for value in options.values()):
raise TypeError("Patch Triton VAE options must be booleans")
if not isinstance(vae, VaeRef) or vae.kind != "VAE":
raise TypeError("Patch Triton VAE needs a VAE ref")
if not torch.cuda.is_available():
raise RuntimeError(
"Patch Triton VAE requires an NVIDIA CUDA device")
try:
from . import _triton_vae
except ImportError as exc:
raise RuntimeError(
"Patch Triton VAE requires the optional triton package") from exc
rt = current_runtime()
source = await rt.refs.resolve(vae)
if (getattr(source, "first_stage_model", None) is None
or getattr(source, "patcher", None) is None):
raise TypeError("Patch Triton VAE needs a valid VAE")
result = _triton_vae.patch_vae(source, **options)
return VaeRef._wrap(await rt.refs.create(
"VAE", result)) # type: ignore[return-value]
async def _clip_tokenize(self, clip: "ClipRef", text: str, kwargs: dict) -> dict:
c = await current_runtime().refs.resolve(clip)
return c.tokenize(text, **(kwargs or {}))
async def _clip_encode_from_tokens_scheduled(
self, clip: "ClipRef", tokens: dict,
add_dict: dict = None) -> "CondRef":
rt = current_runtime()
c = await rt.refs.resolve(clip)
# `add_dict` defaults to {} in core rather than None; passing None
# through would be a different call.
cond = c.encode_from_tokens_scheduled(tokens, add_dict=add_dict or {})
return CondRef._wrap(await rt.refs.create("CONDITIONING", cond)) # type: ignore[return-value]
async def _clip_encode_token_weights_component(
self, clip: "ClipRef", component: str, tokens: list,
) -> tuple[TensorRef, Optional[TensorRef]]:
import math
from comfy import model_management
if component not in {"l", "g"}:
raise ValueError("CLIP component must be 'l' or 'g'")
if not isinstance(tokens, list) or not 1 <= len(tokens) <= 2048:
raise ValueError("CLIP component tokens need 1..2048 chunks")
total = 0
for chunk_index, chunk in enumerate(tokens):
if not isinstance(chunk, list) or not 1 <= len(chunk) <= 4096:
raise ValueError(
f"CLIP token chunk {chunk_index} needs 1..4096 entries")
total += len(chunk)
if total > 131072:
raise ValueError("CLIP component token input is too large")
for entry_index, entry in enumerate(chunk):
if not isinstance(entry, (tuple, list)) or len(entry) != 2:
raise TypeError(
f"CLIP token entry {chunk_index}:{entry_index} must "
"contain token and weight")
weight = entry[1]
if (isinstance(weight, bool)
or not isinstance(weight, (int, float))
or not math.isfinite(float(weight))):
raise ValueError(
f"CLIP token weight {chunk_index}:{entry_index} "
"must be finite")
rt = current_runtime()
c = await rt.refs.resolve(clip)
stage = getattr(c, "cond_stage_model", None)
target = getattr(stage, f"clip_{component}", None)
if target is None:
raise ValueError(
f"this text encoder has no CLIP-{component.upper()} component")
stage.reset_clip_options()
if getattr(c, "layer_idx", None) is not None:
stage.set_clip_options({"layer": c.layer_idx})
c.load_model()
device = c.patcher.load_device
stage.set_clip_options({"execution_device": device})
with model_management.cuda_device_context(device):
output = target.encode_token_weights(tokens)
embedding, pooled = output[:2]
embedding = embedding.detach().to(device="cpu")
embedding_ref = TensorRef._wrap(
await rt.refs.create("TENSOR", embedding)
)
pooled_ref = None
if pooled is not None:
pooled_ref = TensorRef._wrap(await rt.refs.create(
"TENSOR", pooled.detach().to(device="cpu")
))
return embedding_ref, pooled_ref
async def _clip_encode(self, clip: "ClipRef", text: str) -> "CondRef":
rt = current_runtime()
c = await rt.refs.resolve(clip)
tokens = c.tokenize(text)
return CondRef._wrap(await rt.refs.create( # type: ignore[return-value]
"CONDITIONING", c.encode_from_tokens_scheduled(tokens)))
async def _clip_generate_text(
self, clip: "ClipRef", prompt: str,
image: Optional["ImageRef"] = None,
video: Optional["ImageRef"] = None,
max_length: int = 256, do_sample: bool = False,
temperature: float = 1.0, top_k: Optional[int] = 50,
top_p: float = 0.95, min_p: float = 0.0,
repetition_penalty: float = 1.0,
seed: Optional[int] = None, presence_penalty: float = 0.0,
thinking: bool = False, use_default_template: bool = True,
num_beams: int = 1,
) -> str:
from contextlib import nullcontext
import math
import torch
prompt = str(prompt)
if len(prompt) > 32768:
raise ValueError("text-generation prompt exceeds 32768 characters")
if (isinstance(max_length, bool) or not isinstance(max_length, int)
or not 1 <= max_length <= 4096):
raise ValueError("text-generation max_length must be in [1, 4096]")
if (top_k is not None and (
isinstance(top_k, bool) or not isinstance(top_k, int)
or not 0 <= top_k <= 1000
)):
raise ValueError("text-generation top_k must be null or in [0, 1000]")
if (isinstance(num_beams, bool) or not isinstance(num_beams, int)
or not 1 <= num_beams <= 8):
raise ValueError("text-generation num_beams must be in [1, 8]")
if num_beams > 1 and do_sample:
raise ValueError("beam generation cannot also enable sampling")
if (seed is not None and (
isinstance(seed, bool) or not isinstance(seed, int)
or not 0 <= seed <= 0xFFFFFFFFFFFFFFFF
)):
raise ValueError("text-generation seed must be a uint64 or null")
values = {
"temperature": float(temperature),
"top_p": float(top_p),
"min_p": float(min_p),
"repetition_penalty": float(repetition_penalty),
"presence_penalty": float(presence_penalty),
}
if not all(math.isfinite(value) for value in values.values()):
raise ValueError("text-generation numeric options must be finite")
if not 0.0 < values["temperature"] <= 2.0:
raise ValueError("text-generation temperature must be in (0, 2]")
if not 0.0 <= values["top_p"] <= 1.0:
raise ValueError("text-generation top_p must be in [0, 1]")
if not 0.0 <= values["min_p"] <= 1.0:
raise ValueError("text-generation min_p must be in [0, 1]")
if not 0.0 < values["repetition_penalty"] <= 5.0:
raise ValueError(
"text-generation repetition_penalty must be in (0, 5]")
if not 0.0 <= values["presence_penalty"] <= 5.0:
raise ValueError(
"text-generation presence_penalty must be in [0, 5]")
if type(do_sample) is not bool or type(thinking) is not bool:
raise TypeError("text-generation switches must be booleans")
if type(use_default_template) is not bool:
raise TypeError("use_default_template must be a boolean")
rt = current_runtime()
c = await rt.refs.resolve(clip)
if (not callable(getattr(c, "tokenize", None))
or not callable(getattr(c, "generate", None))
or not callable(getattr(c, "decode", None))):
raise ValueError(
"the selected text encoder does not support generation")
pixels = None
if image is not None:
pixels = await rt.refs.resolve(image)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or pixels.shape[0] != 1 or pixels.shape[-1] < 3):
raise ValueError(
"image-conditioned text generation needs one BHWC image")
height, width = map(int, pixels.shape[1:3])
if (height <= 0 or width <= 0
or height * width > 268_435_456):
raise ValueError("text-generation image dimensions are invalid")
video_pixels = None
if video is not None:
video_pixels = await rt.refs.resolve(video)
if (not isinstance(video_pixels, torch.Tensor)
or video_pixels.ndim != 4
or not 1 <= video_pixels.shape[0] <= 64
or video_pixels.shape[-1] < 3):
raise ValueError(
"video-conditioned text generation needs 1 to 64 BHWC frames")
frames, height, width = map(int, video_pixels.shape[:3])
if (height <= 0 or width <= 0
or frames * height * width > 268_435_456):
raise ValueError("text-generation video dimensions are invalid")
family = getattr(c, "_secure_language_family", None)
if top_k is None:
top_k = 20 if isinstance(family, str) and family.startswith("qwen3") else 50
model_lock = getattr(c, "_secure_text_generation_lock", None)
with model_lock if model_lock is not None else nullcontext():
tokens = c.tokenize(
prompt,
image=pixels,
video=video_pixels,
skip_template=not use_default_template,
min_length=1,
thinking=thinking,
)
generate_options = {
"do_sample": do_sample,
"max_length": max_length,
"temperature": values["temperature"],
"top_k": top_k,
"top_p": values["top_p"],
"min_p": values["min_p"],
"repetition_penalty": values["repetition_penalty"],
"seed": seed,
"presence_penalty": values["presence_penalty"],
}
if num_beams != 1:
generate_options["num_beams"] = num_beams
generated = c.generate(tokens, **generate_options)
result = c.decode(generated)
if not isinstance(result, str) or len(result) > 1_048_576:
raise RuntimeError("text encoder returned invalid generated text")
return result.strip()
async def _clip_scale_attention_weights(
self, clip: "ClipRef", clip_l=None, clip_g=None, t5xxl=None,
query: bool = True, key: bool = True,
value: bool = True, output: bool = True,
) -> "ClipRef":
import math
import re
def scales(name, values, expected):
if values is None:
return None
if not isinstance(values, (list, tuple)) or len(values) != expected:
raise ValueError(
f"{name} must contain exactly {expected} layer scales")
checked = []
for index, item in enumerate(values):
if (
isinstance(item, bool)
or not isinstance(item, (int, float))
or not math.isfinite(float(item))
or not 0.0 <= float(item) <= 5.0
):
raise ValueError(
f"{name}[{index}] must be finite and in [0, 5]")
checked.append(float(item))
return checked
clip_l = scales("clip_l", clip_l, 12)
clip_g = scales("clip_g", clip_g, 32)
t5xxl = scales("t5xxl", t5xxl, 24)
switches = (query, key, value, output)
if any(not isinstance(item, bool) for item in switches):
raise TypeError("attention projection switches must be booleans")
rt = current_runtime()
source = await rt.refs.resolve(clip)
patched = source.clone()
state = patched.patcher.model_state_dict()
def selected(key_name, *, t5=False):
if t5:
return (
(query and ".q." in key_name)
or (key and ".k." in key_name)
or (value and ".v." in key_name)
or (output and ".o." in key_name)
)
return (
(query and "q_proj" in key_name)
or (key and "k_proj" in key_name)
or (value and "v_proj" in key_name)
or (output and "out_proj" in key_name)
)
dual_clip = clip_l is not None and clip_g is not None
for key_name in state:
layer_scales = None
layer = None
if "self_attn" in key_name:
match = re.search(r"\.layers\.(\d+)\.", key_name)
if match is not None:
layer = int(match.group(1))
if dual_clip:
if "clip_l" in key_name:
layer_scales = clip_l
elif "clip_g" in key_name:
layer_scales = clip_g
else:
layer_scales = clip_l if clip_l is not None else clip_g
is_selected = selected(key_name)
elif "SelfAttention" in key_name:
match = re.search(r"\.block\.(\d+)\.", key_name)
if match is not None:
layer = int(match.group(1))
layer_scales = t5xxl
is_selected = selected(key_name, t5=True)
else:
continue
if (
layer_scales is not None
and layer is not None
and layer < len(layer_scales)
and layer_scales[layer] != 1.0
and is_selected
):
patched.add_patches(
{key_name: (None,)}, 0.0, layer_scales[layer])
return ClipRef._wrap(
await rt.refs.create("CLIP", patched)
) # type: ignore[return-value]
async def _clip_set_last_layer(
self, clip: "ClipRef", stop_at_clip_layer: int,
) -> "ClipRef":
if isinstance(stop_at_clip_layer, bool):
raise TypeError("CLIP layer must be an integer")
layer = int(stop_at_clip_layer)
if not -24 <= layer <= -1:
raise ValueError("CLIP layer must be in [-24, -1]")
rt = current_runtime()
source = await rt.refs.resolve(clip)
output = source.clone()
output.clip_layer(layer)
return ClipRef._wrap(
await rt.refs.create("CLIP", output)
) # type: ignore[return-value]
async def _clip_with_attention_impl(
self, clip: "ClipRef", mode: str,
) -> "ClipRef":
from comfy.ldm.modules import attention as attn
if not isinstance(mode, str) or not 1 <= len(mode) <= 128:
raise ValueError("CLIP attention mode must be a bounded string")
try:
attention_function = attn.get_attention_function(mode)
except KeyError as error:
raise ValueError(
f"CLIP attention function {mode!r} is not registered"
) from error
if not callable(attention_function):
raise TypeError("registered CLIP attention implementation is invalid")
rt = current_runtime()
source = await rt.refs.resolve(clip)
output = source.clone()
patcher = getattr(output, "patcher", None)
if patcher is None or not isinstance(
getattr(patcher, "model_options", None), dict
):
raise TypeError("CLIP attention selection needs a valid patcher")
def override(_default, *args, **kwargs):
return attention_function(*args, **kwargs)
transformer_options = patcher.model_options.setdefault(
"transformer_options", {})
transformer_options = transformer_options.copy()
transformer_options["optimized_attention_override"] = override
patcher.model_options["transformer_options"] = transformer_options
return ClipRef._wrap(
await rt.refs.create("CLIP", output)
) # type: ignore[return-value]
async def _clip_describe_tokens(
self, clip: "ClipRef", tokens: dict,
) -> dict:
from comfy.sd1_clip import SDTokenizer
if not isinstance(tokens, dict) or not 1 <= len(tokens) <= 16:
raise ValueError("CLIP tokens need 1 to 16 components")
rt = current_runtime()
source = await rt.refs.resolve(clip)
tokenizer_root = getattr(source, "tokenizer", None)
tokenizers = [
value for value in vars(tokenizer_root).values()
if isinstance(value, SDTokenizer)
] if tokenizer_root is not None else []
descriptions = {}
total = 0
for tokenizer in tokenizers:
key = str(tokenizer.embedding_key).replace("clip_", "")
if key not in tokens:
continue
chunks = tokens[key]
if not isinstance(chunks, list) or not 1 <= len(chunks) <= 2048:
raise ValueError(f"CLIP token component {key!r} is invalid")
special = {
value for value in (
tokenizer.start_token,
tokenizer.end_token,
tokenizer.pad_token,
) if isinstance(value, int)
}
inv_vocab = getattr(tokenizer, "inv_vocab", None)
if not isinstance(inv_vocab, dict):
raise TypeError(
f"CLIP tokenizer {key!r} has no inverse vocabulary")
described_chunks = []
for chunk_index, chunk in enumerate(chunks):
if not isinstance(chunk, list) or len(chunk) > 4096:
raise ValueError(
f"CLIP token chunk {key!r}:{chunk_index} is invalid")
described = []
for entry_index, entry in enumerate(chunk):
if (not isinstance(entry, (tuple, list)) or len(entry) < 1
or isinstance(entry[0], bool)
or not isinstance(entry[0], int)):
raise TypeError(
f"CLIP token {key!r}:{chunk_index}:"
f"{entry_index} has an invalid ID")
token_id = entry[0]
token_text = inv_vocab.get(token_id)
if not isinstance(token_text, str):
raise ValueError(
f"CLIP token {token_id} has no text description")
if len(token_text.encode("utf-8")) > 1024:
raise ValueError("CLIP token text exceeds 1024 bytes")
described.append({
"id": token_id,
"text": token_text,
"special": token_id in special,
})
total += 1
if total > 32768:
raise ValueError(
"CLIP token descriptions exceed 32768 entries")
described_chunks.append(described)
descriptions[key] = described_chunks
missing = set(tokens) - set(descriptions)
if missing:
raise ValueError(
f"CLIP token components are not describable: {sorted(missing)}")
return descriptions
async def _gligen_apply_batched(
self, gligen: "GligenRef", conditioning: "CondRef", clip: "ClipRef",
text: str, boxes: list,
) -> "CondRef":
import math
if not isinstance(text, str):
raise TypeError("GLIGEN text must be a string")
if not isinstance(boxes, (list, tuple)):
raise TypeError("GLIGEN boxes must be a list")
if len(boxes) > 4096:
raise ValueError("GLIGEN boxes are limited to 4096 batch items")
checked = []
for index, box in enumerate(boxes):
if not isinstance(box, (list, tuple)) or len(box) != 4:
raise TypeError(
f"GLIGEN box {index} must contain height, width, y, x")
height, width, y, x = box
if any(isinstance(value, bool) for value in box):
raise TypeError(f"GLIGEN box {index} cannot contain booleans")
if not isinstance(height, int) or not isinstance(width, int):
raise TypeError(
f"GLIGEN box {index} height and width must be integers")
if not all(isinstance(value, (int, float)) for value in (y, x)):
raise TypeError(
f"GLIGEN box {index} y and x must be numbers")
if not all(math.isfinite(float(value)) for value in (y, x)):
raise ValueError(
f"GLIGEN box {index} y and x must be finite")
checked.append((height, width, y, x))
rt = current_runtime()
gligen_value = await rt.refs.resolve(gligen)
source = await rt.refs.resolve(conditioning)
clip_value = await rt.refs.resolve(clip)
_, pooled = clip_value.encode_from_tokens(
clip_value.tokenize(text), return_pooled=True)
positions = [
[(pooled, height, width, y, x)]
for height, width, y, x in checked
]
result = []
for item in source:
metadata = item[1].copy()
previous = (
metadata["gligen"][2]
if "gligen" in metadata else [[] for _ in checked]
)
combined = [
prior + current
for prior, current in zip(previous, positions)
]
metadata["gligen"] = (
"position_batched", gligen_value, combined)
result.append([item[0], metadata])
return CondRef._wrap(await rt.refs.create( # type: ignore[return-value]
"CONDITIONING", result))
async def _cond_combine(self, cond: "CondRef", other: "CondRef") -> "CondRef":
rt = current_runtime()
a = await rt.refs.resolve(cond)
b = await rt.refs.resolve(other)
return CondRef._wrap(await rt.refs.create("CONDITIONING", a + b)) # type: ignore[return-value]
async def _cond_with_mask(
self, cond: "CondRef", mask: "MaskRef", strength: float = 1.0,
set_area_to_bounds: bool = False,
) -> "CondRef":
import math
from nodes import ConditioningSetMask
strength = float(strength)
if not math.isfinite(strength) or not 0.0 <= strength <= 10.0:
raise ValueError(
"conditioning mask strength must be finite and in [0, 10]")
if type(set_area_to_bounds) is not bool:
raise TypeError("set_area_to_bounds must be a bool")
rt = current_runtime()
conditioning = await rt.refs.resolve(cond)
mask_value = await rt.refs.resolve(mask)
area = "mask bounds" if set_area_to_bounds else "default"
result = ConditioningSetMask().append(
conditioning, mask_value, area, strength)[0]
return CondRef._wrap(await rt.refs.create(
"CONDITIONING", result)) # type: ignore[return-value]
async def _cond_with_clip_vision_output(
self, cond: "CondRef", output: "ClipVisionOutputRef",
) -> "CondRef":
import torch
rt = current_runtime()
conditioning = await rt.refs.resolve(cond)
vision = await rt.refs.resolve(output)
states = getattr(vision, "penultimate_hidden_states", None)
if (
not isinstance(conditioning, (list, tuple))
or not conditioning
or not isinstance(states, torch.Tensor)
or states.ndim < 2
or states.numel() < 1
or states.numel() > 268_435_456
):
raise ValueError(
"clip-vision conditioning needs bounded conditioning and "
"penultimate hidden states")
result = []
for index, item in enumerate(conditioning):
if (
not isinstance(item, (list, tuple))
or len(item) != 2
or not isinstance(item[1], dict)
):
raise TypeError(
f"conditioning row {index} has an invalid shape")
metadata = item[1].copy()
metadata["clip_vision_output"] = vision
result.append([item[0], metadata])
return CondRef._wrap(await rt.refs.create(
"CONDITIONING", result)) # type: ignore[return-value]
async def _cond_with_concat_latent(
self, cond: "CondRef", model: "ModelRef", latent: "LatentRef",
extra_latent: Optional["LatentRef"] = None,
) -> "CondRef":
import copy
import torch
from comfy.conds import CONDRegular
rt = current_runtime()
conditioning = await rt.refs.resolve(cond)
model_value = await rt.refs.resolve(model)
latent_value = await rt.refs.resolve(latent)
extra_value = (
None if extra_latent is None
else await rt.refs.resolve(extra_latent))
if not isinstance(conditioning, (list, tuple)) or not conditioning:
raise TypeError(
"concat-latent conditioning must contain embedding rows")
def samples(value: Any, name: str):
result = value.get("samples") if isinstance(value, dict) else None
if (
not isinstance(result, torch.Tensor)
or result.ndim != 4
or not 1 <= result.shape[0] <= 64
or not 1 <= result.shape[1] <= 64
or result.shape[2] <= 0
or result.shape[3] <= 0
or result.numel() > 268_435_456
):
raise ValueError(
f"{name} must contain a bounded BCHW latent tensor")
return result
tensors = [samples(latent_value, "latent")]
if extra_value is not None:
tensors.append(samples(extra_value, "extra_latent"))
if any(
tensor.shape[0] != tensors[0].shape[0]
or tensor.shape[2:] != tensors[0].shape[2:]
for tensor in tensors[1:]
):
raise ValueError(
"concat latents must share batch and spatial dimensions")
concat = torch.cat(tensors, dim=1)
latent_format = getattr(
getattr(model_value, "model", None), "latent_format", None)
process = getattr(latent_format, "process_in", None)
if not callable(process):
raise ValueError(
"the selected model has no latent-format converter")
formatted = process(concat)
if not isinstance(formatted, torch.Tensor):
raise TypeError("model latent-format conversion returned no tensor")
result = []
for index, row in enumerate(conditioning):
if (
not isinstance(row, (list, tuple))
or len(row) != 2
or not isinstance(row[1], dict)
):
raise TypeError(
f"conditioning row {index} has an invalid structure")
metadata = copy.copy(row[1])
model_conds = copy.copy(metadata.get("model_conds") or {})
model_conds["c_concat"] = CONDRegular(formatted)
metadata["model_conds"] = model_conds
result.append([row[0], metadata])
return CondRef._wrap(await rt.refs.create(
"CONDITIONING", result)) # type: ignore[return-value]
async def _cond_spatial_crop(
self, cond: "CondRef", x: int, y: int, width: int, height: int,
source_width: int, source_height: int,
target_width: Optional[int] = None,
target_height: Optional[int] = None,
) -> "CondRef":
import torch
import torch.nn.functional as F
values = {
"x": x, "y": y, "width": width, "height": height,
"source_width": source_width, "source_height": source_height,
}
for name, value in values.items():
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"conditioning crop {name} must be an integer")
if not 1 <= source_width <= 16384 or not 1 <= source_height <= 16384:
raise ValueError(
"conditioning crop source dimensions must be in [1, 16384]")
if width < 1 or height < 1 or x < 0 or y < 0:
raise ValueError("conditioning crop window must be positive")
if x + width > source_width or y + height > source_height:
raise ValueError("conditioning crop window exceeds its source")
if (target_width is None) != (target_height is None):
raise ValueError(
"conditioning crop target width and height must be paired")
if target_width is not None and (
isinstance(target_width, bool)
or isinstance(target_height, bool)
or not isinstance(target_width, int)
or not isinstance(target_height, int)
or not 1 <= target_width <= 16384
or not 1 <= target_height <= 16384
):
raise ValueError(
"conditioning crop target dimensions must be integers "
"in [1, 16384]")
rt = current_runtime()
source = await rt.refs.resolve(cond)
if not isinstance(source, (list, tuple)):
raise TypeError("conditioning must be a list of embedding rows")
def crop_spatial_tensor(value: Any) -> Any:
if not isinstance(value, torch.Tensor) or value.ndim < 2:
return value
full_height, full_width = value.shape[-2:]
if full_height <= 1 and full_width <= 1:
return value.clone()
left = round(x * full_width / source_width)
right = round((x + width) * full_width / source_width)
top = round(y * full_height / source_height)
bottom = round((y + height) * full_height / source_height)
left = min(max(0, left), full_width - 1)
top = min(max(0, top), full_height - 1)
right = min(full_width, max(left + 1, right))
bottom = min(full_height, max(top + 1, bottom))
cropped = value[..., top:bottom, left:right].clone()
if target_width is None:
return cropped
scaled_width = max(1, round(
target_width * full_width / source_width))
scaled_height = max(1, round(
target_height * full_height / source_height))
if tuple(cropped.shape[-2:]) == (scaled_height, scaled_width):
return cropped
original_dtype = cropped.dtype
leading = tuple(cropped.shape[:-2])
resized = F.interpolate(
cropped.reshape(-1, 1, *cropped.shape[-2:]).float(),
size=(scaled_height, scaled_width),
mode="bilinear", align_corners=False,
)
return resized.reshape(
*leading, scaled_height, scaled_width).to(
dtype=original_dtype)
controls: dict[int, Any] = {}
def crop_control(control: Any) -> Any:
if control is None:
return None
identity = id(control)
if identity in controls:
return controls[identity]
copy_method = getattr(control, "copy", None)
if not callable(copy_method):
# Unknown conditioning extensions remain opaque. Core-owned
# ControlNet/T2I types all implement copy().
return control
clone = copy_method()
controls[identity] = clone
if hasattr(control, "cond_hint_original"):
clone.cond_hint_original = crop_spatial_tensor(
control.cond_hint_original)
if hasattr(clone, "cond_hint"):
clone.cond_hint = None
if hasattr(clone, "control_input"):
clone.control_input = None
if hasattr(control, "extra_concat_orig"):
clone.extra_concat_orig = [
crop_spatial_tensor(item)
for item in control.extra_concat_orig
]
previous = crop_control(
getattr(control, "previous_controlnet", None))
setter = getattr(clone, "set_previous_controlnet", None)
if callable(setter):
setter(previous)
elif hasattr(clone, "previous_controlnet"):
clone.previous_controlnet = previous
return clone
def resolve_area(area: Any) -> tuple[int, int, int, int] | None:
if not isinstance(area, (tuple, list)):
return None
if len(area) == 5 and area[0] == "percentage":
return (
max(1, round(float(area[1]) * source_height)),
max(1, round(float(area[2]) * source_width)),
round(float(area[3]) * source_height),
round(float(area[4]) * source_width),
)
if len(area) != 4:
return None
return tuple(int(item) for item in area)
def crop_area(area: Any) -> tuple[int, int, int, int] | None:
resolved = resolve_area(area)
if resolved is None:
return None
area_height, area_width, area_y, area_x = resolved
left = max(x, area_x)
top = max(y, area_y)
right = min(x + width, area_x + area_width)
bottom = min(y + height, area_y + area_height)
if right <= left or bottom <= top:
return () # type: ignore[return-value]
area = (bottom - top, right - left, top - y, left - x)
if target_width is None:
return area
area_height, area_width, area_y, area_x = area
return (
max(1, round(area_height * target_height / height)),
max(1, round(area_width * target_width / width)),
round(area_y * target_height / height),
round(area_x * target_width / width),
)
def crop_mask(mask: Any) -> Any:
tensor = torch.as_tensor(mask)
original_ndim = tensor.ndim
if original_ndim == 2:
tensor = tensor.unsqueeze(0)
if tensor.ndim == 3:
tensor = tensor.unsqueeze(1)
elif tensor.ndim != 4 or tensor.shape[1] != 1:
raise ValueError(
"conditioning crop mask must be HW, BHW, or B1HW")
tensor = tensor.float()
if tuple(tensor.shape[-2:]) != (source_height, source_width):
tensor = F.interpolate(
tensor, size=(source_height, source_width),
mode="bilinear", align_corners=False)
tensor = tensor[..., y:y + height, x:x + width]
if target_width is not None and tuple(tensor.shape[-2:]) != (
target_height, target_width,
):
tensor = F.interpolate(
tensor, size=(target_height, target_width),
mode="bilinear", align_corners=False)
if original_ndim == 2:
return tensor[0, 0]
if original_ndim == 3:
return tensor[:, 0]
return tensor
def crop_gligen_positions(positions: Any) -> Any:
if not isinstance(positions, (list, tuple)):
return positions
output = []
for position in positions:
if (
isinstance(position, (list, tuple))
and len(position) == 5
and all(isinstance(item, (int, float))
for item in position[1:])
):
embedding, item_height, item_width, item_y, item_x = position
cropped = crop_area((
item_height, item_width, item_y, item_x))
if cropped:
output.append((embedding, *cropped))
elif isinstance(position, (list, tuple)):
output.append(crop_gligen_positions(position))
else:
output.append(position)
return output
def has_gligen_position(positions: Any) -> bool:
if not isinstance(positions, (list, tuple)):
return False
if (
len(positions) == 5
and all(isinstance(item, (int, float))
for item in positions[1:])
):
return True
return any(has_gligen_position(item) for item in positions)
result = []
for row in source:
if not isinstance(row, (list, tuple)) or len(row) < 2:
raise TypeError(
"conditioning rows must contain embedding and metadata")
metadata = dict(row[1])
if "area" in metadata:
cropped_area = crop_area(metadata["area"])
if not cropped_area:
continue
metadata["area"] = cropped_area
if "mask" in metadata:
metadata["mask"] = crop_mask(metadata["mask"])
if not torch.any(metadata["mask"] != 0):
continue
if "gligen" in metadata:
gligen = metadata["gligen"]
if isinstance(gligen, (list, tuple)) and len(gligen) == 3:
positions = crop_gligen_positions(gligen[2])
if has_gligen_position(positions):
metadata["gligen"] = (
gligen[0], gligen[1], positions)
else:
metadata.pop("gligen")
if "control" in metadata:
metadata["control"] = crop_control(metadata["control"])
if isinstance(metadata.get("reference_latents"), (list, tuple)):
metadata["reference_latents"] = [
crop_spatial_tensor(item)
for item in metadata["reference_latents"]
]
result.append([row[0], metadata])
return CondRef._wrap(await rt.refs.create( # type: ignore[return-value]
"CONDITIONING", result))
async def _latent_minimax_h3_token_count(
self, latent: "LatentRef", conditioning: "CondRef",
) -> dict[str, Any]:
import inspect
try:
from comfy.ldm.minimax.model import PackedLayout
except ImportError as error:
raise RuntimeError(
"MiniMax H3 token counting requires core MiniMax H3 support; "
"update ComfyUI") from error
rt = current_runtime()
latent_value = await rt.refs.resolve(latent)
conditioning_value = await rt.refs.resolve(conditioning)
if not isinstance(latent_value, dict) or "samples" not in latent_value:
raise TypeError("MiniMax H3 token counting needs a LATENT with samples")
samples = latent_value["samples"]
if getattr(samples, "is_nested", False):
video, audio = samples.unbind()[:2]
audio_length = audio.shape[-1]
else:
video, audio_length = samples, 0
if video.ndim != 5:
raise ValueError(
"MiniMax H3 token counting expected a video latent of shape "
f"[B, C, T, H, W], got {tuple(video.shape)}")
latent_length = video.shape[2]
latent_height = (video.shape[3] + 1) // 2 * 2
latent_width = (video.shape[4] + 1) // 2 * 2
supported = set(inspect.signature(PackedLayout.__init__).parameters)
def build_layout(condition, metadata):
options = {
"keyframes": metadata.get("minimax_keyframes"),
"refs": metadata.get("minimax_refs"),
"frame_count": metadata.get("minimax_frame_count"),
}
missing = [
name for name, value in options.items()
if value is not None and name not in supported
]
if missing:
raise RuntimeError(
"this ComfyUI version's MiniMax PackedLayout does not "
f"support {missing}; update ComfyUI")
return PackedLayout(
condition.shape[1], latent_length, latent_height,
latent_width, audio_length,
**{
name: value for name, value in options.items()
if value is not None and name in supported
})
layout = max(
(build_layout(condition, metadata)
for condition, metadata in conditioning_value),
key=lambda item: item.seq_len)
rows = {}
segment_counts = {}
for start, stop, kind in layout.segments:
rows[kind] = rows.get(kind, 0) + stop - start
segment_counts[kind] = segment_counts.get(kind, 0) + 1
parts = [("total", str(layout.seq_len))]
if layout.seq_len * 7168 >= 2**31:
parts.append((
"WARNING",
"over the int32-safe attention range "
f"({2**31 // 7168} tokens), sageattention kernels may overflow",
))
parts.append(("text", str(rows.get("text", 0))))
if "cond" in rows:
count = segment_counts["cond"]
parts.append((
"keyframes",
f"{rows['cond']} ({count} frame{'s' if count > 1 else ''})",
))
if "ref_img" in rows:
count = segment_counts["ref_img"]
parts.append((
"image/video refs",
f"{rows['ref_img']} ({count} block{'s' if count > 1 else ''})",
))
if "ref_audio" in rows:
count = segment_counts["ref_audio"]
parts.append((
"audio refs",
f"{rows['ref_audio']} ({count} block{'s' if count > 1 else ''})",
))
parts.append(("audio", str(rows.get("audio", 0))))
parts.append((
"video",
f"{rows.get('video', 0)} "
f"({latent_length}x{latent_height // 2}x{latent_width // 2} patches)",
))
return {
"tokens": int(layout.seq_len),
"breakdown": "\n".join(f"{key}: {value}" for key, value in parts),
}
async def _sigmas_steps(self, sigmas: "SigmasRef") -> int:
import torch
value = await current_runtime().refs.resolve(sigmas)
if (not isinstance(value, torch.Tensor) or value.ndim != 1
or not 2 <= int(value.numel()) <= 10001
or not torch.isfinite(value).all()):
raise ValueError(
"SIGMAS must contain 2 to 10001 finite scalar values")
return int(value.numel()) - 1
async def _sigmas_value_at(
self, sigmas: "SigmasRef", index: int,
) -> float:
import math
import torch
if isinstance(index, bool) or not isinstance(index, int):
raise TypeError("SIGMAS index must be an integer")
value = await current_runtime().refs.resolve(sigmas)
if (not isinstance(value, torch.Tensor) or value.ndim != 1
or not 1 <= int(value.numel()) <= 10001
or not torch.isfinite(value).all()):
raise ValueError(
"SIGMAS must contain 1 to 10001 finite scalar values")
if not -int(value.numel()) <= index < int(value.numel()):
raise IndexError("SIGMAS index is outside the schedule")
result = float(value[index].item())
if not math.isfinite(result):
raise ValueError("SIGMAS value is not finite")
return result
async def _sampler_named(
self, _subject: Optional["Ref"], name: str,
eta: Optional[float] = None,
ge_gamma: Optional[float] = None,
) -> "SamplerRef":
import math
import comfy.samplers
if not isinstance(name, str) or name not in comfy.samplers.SAMPLER_NAMES:
raise ValueError("unknown core sampler name")
supplied = {
key: value for key, value in {
"eta": eta,
"ge_gamma": ge_gamma,
}.items() if value is not None
}
allowed = {
"euler_ancestral_cfg_pp": {"eta"},
"gradient_estimation": {"ge_gamma"},
"gradient_estimation_cfg_pp": {"ge_gamma"},
}.get(name, set())
unknown = set(supplied) - allowed
if unknown:
raise ValueError(
f"sampler {name!r} does not accept options {sorted(unknown)}")
checked = {}
if eta is not None:
if isinstance(eta, bool) or not isinstance(eta, (int, float)):
raise TypeError("sampler eta must be numeric")
eta = float(eta)
if not math.isfinite(eta) or not 0.0 <= eta <= 100.0:
raise ValueError("sampler eta must be finite and in [0, 100]")
checked["eta"] = eta
if ge_gamma is not None:
if (isinstance(ge_gamma, bool)
or not isinstance(ge_gamma, (int, float))):
raise TypeError("sampler ge_gamma must be numeric")
ge_gamma = float(ge_gamma)
if not math.isfinite(ge_gamma) or not 2.0 <= ge_gamma <= 5.0:
raise ValueError(
"sampler ge_gamma must be finite and in [2, 5]")
checked["ge_gamma"] = ge_gamma
sampler = (
comfy.samplers.ksampler(name, checked)
if checked else comfy.samplers.sampler_object(name)
)
return SamplerRef._wrap(await current_runtime().refs.create(
"SAMPLER", sampler)) # type: ignore[return-value]
async def _cond_sequence_length(self, cond: "CondRef") -> int:
value = await current_runtime().refs.resolve(cond)
if (
not isinstance(value, (list, tuple))
or not value
or not isinstance(value[0], (list, tuple))
or not value[0]
or not hasattr(value[0][0], "shape")
or len(value[0][0].shape) < 2
):
raise TypeError(
"conditioning must contain a sequence-shaped embedding")
return int(value[0][0].shape[1])
async def _cond_zero_out(self, cond: "CondRef") -> "CondRef":
from nodes import ConditioningZeroOut
rt = current_runtime()
source = await rt.refs.resolve(cond)
result = ConditioningZeroOut().zero_out(source)[0]
return CondRef._wrap(await rt.refs.create(
"CONDITIONING", result)) # type: ignore[return-value]
async def _cond_with_timestep_range(
self, cond: "CondRef", start: float, end: float,
) -> "CondRef":
import math
from nodes import ConditioningSetTimestepRange
start = float(start)
end = float(end)
if (not math.isfinite(start) or not math.isfinite(end)
or not 0.0 <= start <= end <= 1.0):
raise ValueError(
"conditioning timestep range must satisfy "
"0 <= start <= end <= 1")
rt = current_runtime()
source = await rt.refs.resolve(cond)
result = ConditioningSetTimestepRange().set_range(
source, start, end)[0]
return CondRef._wrap(await rt.refs.create(
"CONDITIONING", result)) # type: ignore[return-value]
async def _cond_with_metadata(
self, cond: "CondRef", width=None, height=None,
crop_w=None, crop_h=None, target_width=None, target_height=None,
) -> "CondRef":
import node_helpers
from nodes import MAX_RESOLUTION
values = {
key: value for key, value in {
"width": width,
"height": height,
"crop_w": crop_w,
"crop_h": crop_h,
"target_width": target_width,
"target_height": target_height,
}.items() if value is not None
}
if not values:
raise ValueError("conditioning metadata needs at least one field")
for key, value in values.items():
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"conditioning metadata {key} must be an int")
if not 0 <= value <= MAX_RESOLUTION:
raise ValueError(
f"conditioning metadata {key} is outside the host limit")
rt = current_runtime()
source = await rt.refs.resolve(cond)
result = node_helpers.conditioning_set_values(source, values)
return CondRef._wrap(await rt.refs.create(
"CONDITIONING", result)) # type: ignore[return-value]
async def _cond_has_spatial_metadata(self, cond: "CondRef") -> bool:
"""Inspect metadata shape, never embeddings, for safe tile batching."""
value = await current_runtime().refs.resolve(cond)
if not isinstance(value, (list, tuple)):
raise TypeError("conditioning must be a list of embedding rows")
spatial_keys = {
"area", "mask", "gligen", "control", "reference_latents",
}
for index, row in enumerate(value):
if (
not isinstance(row, (list, tuple))
or len(row) < 2
or not isinstance(row[1], dict)
):
raise TypeError(
f"conditioning row {index} has an invalid structure")
if spatial_keys.intersection(row[1]):
return True
return False
async def _cond_concat(self, cond: "CondRef", other: "CondRef") -> "CondRef":
rt = current_runtime()
to_concat = await rt.refs.resolve(cond)
source = await rt.refs.resolve(other)
import torch
out = []
cond_from = source[0][0]
for t in to_concat:
tw = torch.cat((t[0], cond_from), 1)
out.append([tw, t[1].copy()])
return CondRef._wrap(await rt.refs.create("CONDITIONING", out)) # type: ignore[return-value]
async def _advanced_control_weights_from_list(
self, _subject: Optional["Ref"], weights: list,
uncond_multiplier: float = 1.0, extras: Any = None,
) -> tuple["ControlNetWeightsRef", "TimestepKeyframeRef"]:
import math
if not isinstance(weights, (list, tuple)):
raise TypeError("ControlNet weights must be a list")
if len(weights) > 4096:
raise ValueError("ControlNet weights are limited to 4096 values")
checked_weights = []
for value in weights:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError("ControlNet weights must contain only numbers")
number = float(value)
if not math.isfinite(number):
raise ValueError("ControlNet weights must be finite")
checked_weights.append(number)
if (isinstance(uncond_multiplier, bool)
or not isinstance(uncond_multiplier, (int, float))):
raise TypeError("uncond_multiplier must be a number")
multiplier = float(uncond_multiplier)
if not math.isfinite(multiplier) or not 0.0 <= multiplier <= 1.0:
raise ValueError("uncond_multiplier must be finite and in [0, 1]")
rt = current_runtime()
checked_extras = extras
if isinstance(checked_extras, Ref):
if checked_extras.kind != "VALUE":
raise TypeError("ControlNet extras ref must contain VALUE data")
checked_extras = await rt.refs.resolve(checked_extras)
if checked_extras is None:
checked_extras = {}
if not isinstance(checked_extras, dict):
raise TypeError("ControlNet extras must be a mapping")
def validate_extra(value: Any, depth: int = 0) -> None:
if depth > 32:
raise ValueError("ControlNet extras nesting exceeds 32 levels")
if _looks_like_tensor(value):
return
if value is None or isinstance(value, (str, bool, int)):
return
if isinstance(value, float):
if not math.isfinite(value):
raise ValueError("ControlNet extras must contain finite numbers")
return
if isinstance(value, (list, tuple)):
for item in value:
validate_extra(item, depth + 1)
return
if isinstance(value, dict):
if not all(isinstance(key, str) for key in value):
raise TypeError("ControlNet extras keys must be strings")
for item in value.values():
validate_extra(item, depth + 1)
return
raise TypeError(
f"ControlNet extras cannot contain {type(value).__name__}")
validate_extra(checked_extras)
utils = _advanced_control_module("utils")
control_weights = utils.ControlWeights.controlnet(
weights_input=checked_weights,
uncond_multiplier=multiplier,
extras=checked_extras,
)
keyframe = utils.TimestepKeyframe(
control_weights=control_weights)
shortcut = utils.TimestepKeyframeGroup.default(keyframe)
weights_ref = ControlNetWeightsRef._wrap(await rt.refs.create(
"CONTROL_NET_WEIGHTS", control_weights))
shortcut_ref = TimestepKeyframeRef._wrap(await rt.refs.create(
"TIMESTEP_KEYFRAME", shortcut))
return weights_ref, shortcut_ref
async def _advanced_control_scaled_soft_weights(
self, _subject: Optional["Ref"], base_multiplier: float = 0.825,
uncond_multiplier: float = 1.0,
) -> tuple["ControlNetWeightsRef", "TimestepKeyframeRef"]:
import math
def multiplier(value: Any, field: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{field} must be a number")
result = float(value)
if not math.isfinite(result) or not 0.0 <= result <= 1.0:
raise ValueError(f"{field} must be finite and in [0, 1]")
return result
base = multiplier(base_multiplier, "base_multiplier")
uncond = multiplier(uncond_multiplier, "uncond_multiplier")
utils = _advanced_control_module("utils")
control_weights = utils.ControlWeights.universal(
base_multiplier=base,
uncond_multiplier=uncond,
extras={},
)
shortcut = utils.TimestepKeyframeGroup.default(
utils.TimestepKeyframe(control_weights=control_weights))
rt = current_runtime()
return (
ControlNetWeightsRef._wrap(await rt.refs.create(
"CONTROL_NET_WEIGHTS", control_weights)),
TimestepKeyframeRef._wrap(await rt.refs.create(
"TIMESTEP_KEYFRAME", shortcut)),
) # type: ignore[return-value]
async def _model_apply_lora(
self, model: "ModelRef", asset: "AssetRef",
clip: Optional["ClipRef"], strength_model: float,
strength_clip: float,
) -> tuple["ModelRef", Optional["ClipRef"]]:
import math
import comfy.sd
import comfy.utils
import folder_paths
strengths = {}
for name, value in {
"strength_model": strength_model,
"strength_clip": strength_clip,
}.items():
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{name} must be a number")
checked = float(value)
if not math.isfinite(checked) or not -100.0 <= checked <= 100.0:
raise ValueError(f"{name} must be finite and in [-100, 100]")
strengths[name] = checked
if not isinstance(model, ModelRef) or model.kind != "MODEL":
raise TypeError("LoRA application needs a MODEL ref")
if not isinstance(asset, AssetRef) or asset.kind != "ASSET":
raise TypeError("LoRA application needs an ASSET ref")
if clip is not None and (
not isinstance(clip, ClipRef) or clip.kind != "CLIP"):
raise TypeError("clip must be a CLIP ref or None")
if clip is None and strengths["strength_clip"] != 0.0:
raise ValueError("strength_clip must be zero when clip is None")
if (strengths["strength_model"] == 0.0
and strengths["strength_clip"] == 0.0):
return model, clip
rt = current_runtime()
source_model = await rt.refs.resolve(model)
source_clip = None if clip is None else await rt.refs.resolve(clip)
path = await rt.refs.resolve(asset)
if not isinstance(path, (str, os.PathLike)):
raise TypeError("LoRA ASSET ref does not contain a path")
path = _InProcessAssets._confined_resolved_path(
path, folder_paths.get_folder_paths("loras"), "loras")
state_dict, metadata = comfy.utils.load_torch_file(
path, safe_load=True, return_metadata=True)
if not isinstance(state_dict, dict):
raise TypeError("LoRA asset must contain a state-dict mapping")
patched_model, patched_clip = comfy.sd.load_lora_for_models(
source_model,
source_clip,
state_dict,
strengths["strength_model"],
strengths["strength_clip"],
lora_metadata=metadata,
)
model_ref = ModelRef._wrap(await rt.refs.create(
"MODEL", patched_model))
clip_ref = None
if patched_clip is not None:
clip_ref = ClipRef._wrap(await rt.refs.create(
"CLIP", patched_clip))
return model_ref, clip_ref
async def _model_apply_dit_block_lora(
self, model: "ModelRef", asset: "AssetRef", strength_model: float,
block_weights: list[dict[str, Any]],
) -> tuple["ModelRef", str]:
import math
import comfy.lora
import folder_paths
from comfy.utils import load_torch_file
if (isinstance(strength_model, bool)
or not isinstance(strength_model, (int, float))):
raise TypeError("strength_model must be a number")
strength = float(strength_model)
if not math.isfinite(strength) or not -100.0 <= strength <= 100.0:
raise ValueError("strength_model must be finite and in [-100, 100]")
if not isinstance(block_weights, list) or len(block_weights) > 108:
raise TypeError("block_weights must be a closed block-selection list")
limits = {"double_blocks": 20, "single_blocks": 40, "blocks": 48}
selected = []
seen = set()
for item in block_weights:
if not isinstance(item, dict) or set(item) != {
"family", "index", "ratio"}:
raise TypeError("each block selection needs family, index, ratio")
family = item["family"]
index = item["index"]
ratio = item["ratio"]
if family not in limits:
raise ValueError(f"unsupported block family {family!r}")
if isinstance(index, bool) or not isinstance(index, int):
raise TypeError("block index must be an integer")
if not 0 <= index < limits[family]:
raise ValueError("block index is outside its closed family")
if isinstance(ratio, bool) or not isinstance(ratio, (int, float)):
raise TypeError("block ratio must be a number")
ratio = float(ratio)
if not math.isfinite(ratio) or not 0.0 <= ratio <= 10000.0:
raise ValueError("block ratio must be finite and in [0, 10000]")
identity = (family, index)
if identity in seen:
raise ValueError("duplicate block selection")
seen.add(identity)
selected.append((f"{family}.{index}.", ratio))
rt = current_runtime()
source_model = await rt.refs.resolve(model)
if not isinstance(asset, AssetRef) or asset.kind != "ASSET":
raise TypeError("DiT LoRA application needs an ASSET ref")
path = await rt.refs.resolve(asset)
if not isinstance(path, (str, os.PathLike)):
raise TypeError("LoRA ASSET ref does not contain a path")
path = _InProcessAssets._confined_resolved_path(
path, folder_paths.get_folder_paths("loras"), "loras")
lora = load_torch_file(path, safe_load=True)
if not isinstance(lora, dict):
raise TypeError("LoRA asset must contain a state-dict mapping")
weight_key = next(
(key for key in lora if isinstance(key, str)
and key.endswith("weight")), None)
if weight_key is None:
rank = "Couldn't find rank"
else:
weight = lora[weight_key]
if not hasattr(weight, "shape") or len(weight.shape) < 1:
raise TypeError("first LoRA weight has no rank dimension")
rank = str(weight.shape[0])
key_map = comfy.lora.model_lora_keys_unet(source_model.model, {})
loaded = comfy.lora.load_lora(lora, key_map)
for prefix, ratio in selected:
for key in list(loaded):
if isinstance(key, str):
matched = prefix in key
elif isinstance(key, tuple):
matched = any(
isinstance(part, str) and prefix in part
for part in key)
else:
matched = False
if not matched:
continue
if ratio == 0.0:
del loaded[key]
continue
value = loaded[key]
if hasattr(value, "weights"):
values = list(value.weights)
if len(values) < 3:
raise TypeError("LoRA adapter weights have no alpha slot")
values[2] = ratio
value.weights = tuple(values)
patched = source_model.clone()
patched.add_patches(loaded, strength)
return ModelRef._wrap(await rt.refs.create("MODEL", patched)), rank
async def _model_apply_ltx2_lora(
self, model: "ModelRef", asset: "AssetRef", strength_model: float,
block_weights: list[dict[str, Any]], video: float,
video_to_audio: float, audio: float, audio_to_video: float,
other: float,
) -> tuple["ModelRef", str, str]:
import math
import comfy.lora
import folder_paths
from comfy.utils import load_torch_file
if (isinstance(strength_model, bool)
or not isinstance(strength_model, (int, float))):
raise TypeError("strength_model must be a number")
strength = float(strength_model)
if not math.isfinite(strength) or not -100.0 <= strength <= 100.0:
raise ValueError("strength_model must be finite and in [-100, 100]")
layer_strengths = {}
for name, value in {
"video": video,
"video_to_audio": video_to_audio,
"audio": audio,
"audio_to_video": audio_to_video,
"other": other,
}.items():
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{name} must be a number")
value = float(value)
if not math.isfinite(value) or not 0.0 <= value <= 1.0:
raise ValueError(f"{name} must be finite and in [0, 1]")
layer_strengths[name] = value
if not isinstance(block_weights, list) or len(block_weights) > 48:
raise TypeError("block_weights must be a closed LTX2 block list")
selected = []
seen = set()
for item in block_weights:
if not isinstance(item, dict) or set(item) != {
"family", "index", "ratio"}:
raise TypeError("each block selection needs family, index, ratio")
family = item["family"]
index = item["index"]
ratio = item["ratio"]
if family != "blocks":
raise ValueError(f"unsupported LTX2 block family {family!r}")
if isinstance(index, bool) or not isinstance(index, int):
raise TypeError("block index must be an integer")
if not 0 <= index < 48:
raise ValueError("block index is outside the LTX2 block family")
if isinstance(ratio, bool) or not isinstance(ratio, (int, float)):
raise TypeError("block ratio must be a number")
ratio = float(ratio)
if not math.isfinite(ratio) or not 0.0 <= ratio <= 10000.0:
raise ValueError("block ratio must be finite and in [0, 10000]")
if index in seen:
raise ValueError("duplicate LTX2 block selection")
seen.add(index)
selected.append((f"blocks.{index}.", ratio))
rt = current_runtime()
source_model = await rt.refs.resolve(model)
if not isinstance(asset, AssetRef) or asset.kind != "ASSET":
raise TypeError("LTX2 LoRA application needs an ASSET ref")
path = await rt.refs.resolve(asset)
if not isinstance(path, (str, os.PathLike)):
raise TypeError("LoRA ASSET ref does not contain a path")
path = _InProcessAssets._confined_resolved_path(
path, folder_paths.get_folder_paths("loras"), "loras")
lora = load_torch_file(path, safe_load=True)
if not isinstance(lora, dict):
raise TypeError("LoRA asset must contain a state-dict mapping")
weight_key = next(
(key for key in lora if isinstance(key, str)
and key.endswith("weight")), None)
if weight_key is None:
rank = "Couldn't find rank"
else:
weight = lora[weight_key]
if not hasattr(weight, "shape") or len(weight.shape) < 1:
raise TypeError("first LoRA weight has no rank dimension")
rank = str(weight.shape[0])
key_map = comfy.lora.model_lora_keys_unet(source_model.model, {})
loaded = comfy.lora.load_lora(lora, key_map)
if not isinstance(loaded, dict):
raise TypeError("mapped LoRA patches must be a dictionary")
keys_to_delete = []
for prefix, ratio in selected:
for key in list(loaded):
if isinstance(key, str):
matched = prefix in key
elif isinstance(key, tuple):
matched = any(
isinstance(part, str) and prefix in part
for part in key)
else:
matched = False
if not matched:
continue
if ratio == 0.0:
keys_to_delete.append(key)
continue
value = loaded[key]
if hasattr(value, "weights"):
values = list(value.weights)
if len(values) < 3:
raise TypeError("LoRA adapter weights have no alpha slot")
values[2] = ratio
value.weights = tuple(values)
for key in list(loaded):
if key in keys_to_delete:
continue
key_str = (
key if isinstance(key, str)
else key[0] if isinstance(key, tuple)
else str(key)
)
if "video_to_audio_attn" in key_str:
multiplier = layer_strengths["video_to_audio"]
elif "audio_to_video_attn" in key_str:
multiplier = layer_strengths["audio_to_video"]
elif "audio_attn" in key_str or "audio_ff.net" in key_str:
multiplier = layer_strengths["audio"]
elif "attn" in key_str or "ff.net" in key_str:
multiplier = layer_strengths["video"]
else:
multiplier = layer_strengths["other"]
if multiplier == 0.0:
keys_to_delete.append(key)
elif multiplier != 1.0:
value = loaded[key]
if hasattr(value, "weights"):
values = list(value.weights)
if len(values) < 3:
raise TypeError("LoRA adapter weights have no alpha slot")
alpha = values[2] if values[2] is not None else 1.0
values[2] = alpha * multiplier
value.weights = tuple(values)
for key in keys_to_delete:
loaded.pop(key, None)
loaded_keys = []
for key, value in loaded.items():
key_str = key if isinstance(key, str) else str(key)
if hasattr(value, "weights"):
alpha = value.weights[2]
alpha = alpha if alpha is not None else "None"
loaded_keys.append(f"{key_str}: alpha={alpha}")
else:
loaded_keys.append(
f"{key_str}: type={type(value).__name__}")
patched = source_model.clone()
accepted = set(patched.add_patches(loaded, strength))
for key in loaded:
if key not in accepted:
key_str = key if isinstance(key, str) else str(key)
loaded_keys.append(f"NOT LOADED: {key_str}")
info = "\n".join(loaded_keys)
return (
ModelRef._wrap(await rt.refs.create("MODEL", patched)), rank, info)
async def _lora_weight_differences(
self, finetuned: "Ref", original: "Ref", include_bias: bool = False,
) -> "WeightDiffCursorRef":
if type(include_bias) is not bool:
raise TypeError("include_bias must be a boolean")
rt = current_runtime()
finetuned_value = await rt.refs.resolve(finetuned)
original_value = await rt.refs.resolve(original)
if finetuned.kind != original.kind:
raise TypeError("finetuned and original must both be MODEL or both be CLIP")
if finetuned.kind == "MODEL":
diff = finetuned_value.clone()
diff.add_patches(
original_value.get_key_patches("diffusion_model."),
-1.0, 1.0)
patcher = diff
input_prefix = "diffusion_model."
output_prefix = "diffusion_model."
elif finetuned.kind == "CLIP":
diff = finetuned_value.clone()
patches = {
key: value
for key, value in original_value.get_key_patches().items()
if not key.endswith(".position_ids")
and not key.endswith(".logit_scale")
}
diff.add_patches(patches, -1.0, 1.0)
patcher = diff.patcher
input_prefix = ""
output_prefix = "text_encoders."
else:
raise TypeError("weight differences require MODEL or CLIP refs")
keys = []
names = [name for name, _ in patcher.model.named_parameters()]
names.extend(name for name, _ in patcher.model.named_buffers())
for name in names:
if not name.startswith(input_prefix):
continue
if name.endswith(".weight"):
stem = name[len(input_prefix):-7]
keys.append((name, f"{output_prefix}{stem}", "weight"))
elif include_bias and name.endswith(".bias"):
stem = name[len(input_prefix):-5]
keys.append((name, f"{output_prefix}{stem}", "bias"))
state = _WeightDiffCursorState(patcher=patcher, keys=keys)
return WeightDiffCursorRef._wrap(await rt.refs.create(
"WEIGHT_DIFF_CURSOR", state)) # type: ignore[return-value]
async def _weight_diff_next(
self, cursor: "WeightDiffCursorRef",
) -> Optional[dict[str, Any]]:
rt = current_runtime()
state = await rt.refs.resolve(cursor)
if not isinstance(state, _WeightDiffCursorState):
raise TypeError("weight-difference cursor has invalid host state")
async with state.lock:
if state.current_ref is not None:
await rt.refs.release(state.current_ref)
state.current_ref = None
if state.closed:
return None
if state.index >= len(state.keys):
state.patcher = None
state.keys.clear()
state.closed = True
import comfy.model_management
comfy.model_management.soft_empty_cache()
return None
source_key, output_key, item_kind = state.keys[state.index]
state.index += 1
weight = state.patcher.patch_weight_to_device(
source_key, return_weight=True)
item = {
"output_key": output_key,
"kind": item_kind,
"position": state.index,
"total": len(state.keys),
"tensor": None,
"ndim": None,
}
if weight is None:
return item
item["ndim"] = weight.ndim
if weight.ndim == 5:
return item
state.current_ref = TensorRef._wrap(await rt.refs.create(
"TENSOR", weight)) # type: ignore[assignment]
item["tensor"] = state.current_ref
return item
async def _model_latent_scale_factor(self, model: "ModelRef") -> float:
value = await current_runtime().refs.resolve(model)
return float(value.model.latent_format.scale_factor)
async def _latent_empty(
self, _source, width: int, height: int, batch_size: int = 1,
channels: int = 4,
spatial_downscale_ratio: Optional[int] = None,
) -> "LatentRef":
import torch
values = {
"width": width,
"height": height,
"batch_size": batch_size,
"channels": channels,
}
if any(
isinstance(value, bool) or not isinstance(value, int)
for value in values.values()
):
raise TypeError("empty latent dimensions must be integers")
if (
spatial_downscale_ratio is not None
and (
isinstance(spatial_downscale_ratio, bool)
or not isinstance(spatial_downscale_ratio, int)
)
):
raise TypeError("latent spatial downscale ratio must be an integer")
ratio = 8 if spatial_downscale_ratio is None else spatial_downscale_ratio
if (
not 64 <= width <= 16384
or not 64 <= height <= 16384
or not 1 <= ratio <= 128
or width % ratio
or height % ratio
or not 1 <= batch_size <= 64
or not 1 <= channels <= 128
or batch_size * channels * (width // ratio) * (height // ratio)
> 16_777_216
):
raise ValueError("empty latent dimensions exceed the bounded range")
value = {
"samples": torch.zeros(
(batch_size, channels, height // ratio, width // ratio),
dtype=torch.float32,
)
}
if spatial_downscale_ratio is not None:
# Canonical legacy spelling used by ComfyUI latent dictionaries.
value["downscale_ratio_spacial"] = ratio
return LatentRef._wrap(await current_runtime().refs.create(
"LATENT", value)) # type: ignore[return-value]
async def _latent_spatial_shape(
self, latent: "LatentRef",
) -> tuple[int, int]:
import torch
value = await current_runtime().refs.resolve(latent)
samples = value.get("samples") if isinstance(value, dict) else None
if not isinstance(samples, torch.Tensor) or samples.ndim < 4:
raise TypeError("LATENT must contain a sample tensor with spatial axes")
height, width = map(int, samples.shape[-2:])
if height <= 0 or width <= 0:
raise ValueError("LATENT spatial dimensions must be positive")
return height, width
async def _latent_resize(
self, latent: "LatentRef", width: int, height: int,
method: str = "bilinear",
) -> "LatentRef":
from comfy.utils import common_upscale
import torch
import torch.nn.functional as F
if (
isinstance(width, bool) or not isinstance(width, int)
or isinstance(height, bool) or not isinstance(height, int)
):
raise TypeError("latent resize dimensions must be integers")
if not 1 <= width <= 16_384 or not 1 <= height <= 16_384:
raise ValueError("latent resize dimensions must be in [1, 16384]")
methods = {"nearest-exact", "bilinear", "area", "bicubic", "bislerp"}
if method not in methods:
raise ValueError(f"unknown latent resize method {method!r}")
rt = current_runtime()
source = await rt.refs.resolve(latent)
if not isinstance(source, dict):
raise TypeError("LATENT must be a mapping")
samples = source.get("samples")
if not isinstance(samples, torch.Tensor) or samples.ndim < 4:
raise TypeError("LATENT must contain a sample tensor with spatial axes")
if samples.numel() // max(1, samples.shape[-2] * samples.shape[-1]) * width * height > 67_108_864:
raise ValueError("latent resize output exceeds the bounded tensor size")
result = dict(source)
result["samples"] = common_upscale(
samples, width, height, method, "disabled")
mask = source.get("noise_mask")
if isinstance(mask, torch.Tensor):
mask_value = mask
while mask_value.ndim < 4:
mask_value = mask_value.unsqueeze(1)
result["noise_mask"] = F.interpolate(
mask_value.float(), size=(height, width), mode="bilinear",
align_corners=False,
).to(mask.dtype)
return LatentRef._wrap(await rt.refs.create(
"LATENT", result)) # type: ignore[return-value]
async def _latent_random_noise(
self, latent: "LatentRef", seed: int, source: str = "cpu",
batch_size: Optional[int] = None,
) -> TensorRef:
import math
import numpy as np
import torch
from comfy import model_management
if isinstance(seed, bool) or not isinstance(seed, int):
raise TypeError("noise seed must be an integer")
if not 0 <= seed <= 0xffffffffffffffff:
raise ValueError("noise seed must be in [0, 2**64 - 1]")
if source not in {"cpu", "gpu"}:
raise ValueError("noise source must be 'cpu' or 'gpu'")
rt = current_runtime()
value = await rt.refs.resolve(latent)
if not isinstance(value, dict) or "samples" not in value:
raise TypeError("LATENT ref has no samples")
samples = value["samples"]
if not isinstance(samples, torch.Tensor) or samples.ndim < 2:
raise TypeError("LATENT samples must be a batched tensor")
batch = int(samples.shape[0]) if batch_size is None else batch_size
if isinstance(batch, bool) or not isinstance(batch, int):
raise TypeError("noise batch_size must be an integer or None")
if not 1 <= batch <= int(samples.shape[0]):
raise ValueError("noise batch_size must fit the source latent")
shape = (batch, *samples.shape[1:])
if math.prod(shape) > 2_147_483_648:
raise ValueError("requested noise tensor is too large")
noise_indices = value.get("batch_index")
if noise_indices is not None:
if isinstance(noise_indices, torch.Tensor):
noise_indices = noise_indices.detach().cpu().tolist()
if not isinstance(noise_indices, (list, tuple)):
raise TypeError("latent batch_index must be a sequence")
noise_indices = list(noise_indices[:batch])
if len(noise_indices) != batch or any(
isinstance(index, bool) or not isinstance(index, (int, np.integer))
or not 0 <= int(index) <= 65_535
for index in noise_indices
):
raise ValueError(
"latent batch_index must contain one bounded non-negative "
"integer per requested batch item"
)
noise_indices = [int(index) for index in noise_indices]
device = (
torch.device("cpu")
if source == "cpu"
else model_management.text_encoder_device()
)
generator = torch.Generator(device=device).manual_seed(seed)
source_samples = samples[:batch]
if source == "cpu":
# This is Comfy's canonical CPU stream (float32 generation followed
# by a dtype cast), but with a private generator so the operation
# does not mutate the trusted process's global RNG state.
from comfy.sample import prepare_noise_inner
noise = prepare_noise_inner(
source_samples, generator, noise_indices
)
elif noise_indices is None:
noise = torch.randn(
shape,
dtype=samples.dtype,
layout=samples.layout,
generator=generator,
device=device,
)
else:
unique, inverse = np.unique(noise_indices, return_inverse=True)
selected = []
for index in range(int(unique[-1]) + 1):
item = torch.randn(
(1, *samples.shape[1:]),
dtype=samples.dtype,
layout=samples.layout,
generator=generator,
device=device,
)
if index in unique:
selected.append(item)
noise = torch.cat([selected[index] for index in inverse], dim=0)
noise = noise.to(device="cpu")
return TensorRef._wrap(await rt.refs.create(
"TENSOR", noise)) # type: ignore[return-value]
async def _latent_noise_mask(
self, latent: "LatentRef",
) -> Optional["MaskRef"]:
import torch
rt = current_runtime()
value = await rt.refs.resolve(latent)
mask = value.get("noise_mask") if isinstance(value, dict) else None
if mask is None:
return None
if not isinstance(mask, torch.Tensor) or mask.ndim < 2:
raise TypeError("LATENT noise_mask must be a tensor with spatial axes")
return MaskRef._wrap(await rt.refs.create(
"MASK", mask)) # type: ignore[return-value]
async def _latent_repeat_batch(
self, latent: "LatentRef", amount: int,
) -> "LatentRef":
from nodes import RepeatLatentBatch
if isinstance(amount, bool) or not isinstance(amount, int):
raise TypeError("latent repeat amount must be an integer")
if not 1 <= amount <= 64:
raise ValueError("latent repeat amount must be in [1, 64]")
rt = current_runtime()
value = await rt.refs.resolve(latent)
result = RepeatLatentBatch().repeat(value, amount)[0]
return LatentRef._wrap(await rt.refs.create(
"LATENT", result)) # type: ignore[return-value]
async def _latent_composite(
self, latent: "LatentRef", source: "LatentRef", x: int = 0,
y: int = 0, resize_source: bool = False,
mask: Optional["MaskRef"] = None,
) -> "LatentRef":
from comfy_extras.nodes_mask import LatentCompositeMasked
if (
isinstance(x, bool) or not isinstance(x, int)
or isinstance(y, bool) or not isinstance(y, int)
):
raise TypeError("latent composite coordinates must be integers")
if not -131_072 <= x <= 131_072 or not -131_072 <= y <= 131_072:
raise ValueError("latent composite coordinates are out of range")
if type(resize_source) is not bool:
raise TypeError("latent composite resize_source must be a bool")
rt = current_runtime()
destination_value = await rt.refs.resolve(latent)
source_value = await rt.refs.resolve(source)
mask_value = None if mask is None else await rt.refs.resolve(mask)
result = LatentCompositeMasked.execute(
destination_value, source_value, x, y, resize_source,
mask_value).result[0]
return LatentRef._wrap(await rt.refs.create(
"LATENT", result)) # type: ignore[return-value]
async def _clip_vision_encode_image(
self, clip_vision: "ClipVisionRef", image: "ImageRef",
crop: bool = True,
) -> "ClipVisionOutputRef":
rt = current_runtime()
encoder = await rt.refs.resolve(clip_vision)
pixels = await rt.refs.resolve(image)
output = encoder.encode_image(pixels, crop=bool(crop))
return ClipVisionOutputRef._wrap(await rt.refs.create(
"CLIP_VISION_OUTPUT", output)) # type: ignore[return-value]
async def _clip_vision_output_image_embeds(
self, output: "ClipVisionOutputRef",
) -> "TensorRef":
rt = current_runtime()
value = await rt.refs.resolve(output)
return TensorRef._wrap(await rt.refs.create(
"TENSOR", value.image_embeds)) # type: ignore[return-value]
async def _clip_vision_output_concat(
self, output: "ClipVisionOutputRef",
other: "ClipVisionOutputRef",
) -> "ClipVisionOutputRef":
import torch
import comfy.clip_vision
rt = current_runtime()
left = await rt.refs.resolve(output)
right = await rt.refs.resolve(other)
left_states = getattr(left, "penultimate_hidden_states", None)
right_states = getattr(right, "penultimate_hidden_states", None)
if (
not isinstance(left_states, torch.Tensor)
or not isinstance(right_states, torch.Tensor)
or left_states.ndim < 2
or right_states.ndim != left_states.ndim
or left_states.dtype != right_states.dtype
or left_states.device != right_states.device
or any(
left_states.shape[index] != right_states.shape[index]
for index in range(left_states.ndim)
if index != left_states.ndim - 2
)
):
raise ValueError(
"CLIP-vision outputs must have compatible hidden states")
combined = torch.cat((left_states, right_states), dim=-2)
if combined.numel() < 1 or combined.numel() > 268_435_456:
raise ValueError("combined CLIP-vision output is too large")
result = comfy.clip_vision.Output()
result.penultimate_hidden_states = combined
return ClipVisionOutputRef._wrap(await rt.refs.create(
"CLIP_VISION_OUTPUT", result)) # type: ignore[return-value]
async def _controlnet_with_union_type(
self, control_net: "ControlNetRef", type_number=None,
) -> "ControlNetRef":
rt = current_runtime()
value = await rt.refs.resolve(control_net)
clone = value.copy()
selected = [] if type_number is None else [int(type_number)]
clone.set_extra_arg("control_type", selected)
return ControlNetRef._wrap(await rt.refs.create(
"CONTROL_NET", clone)) # type: ignore[return-value]
async def _controlnet_apply(
self, control_net: "ControlNetRef", positive: "CondRef",
negative: "CondRef", image: "ImageRef", strength: float = 1.0,
start_percent: float = 0.0, end_percent: float = 1.0,
vae: Optional["VaeRef"] = None,
) -> tuple["CondRef", "CondRef"]:
import math
def finite_number(value: Any, field: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"ControlNet {field} must be a number")
result = float(value)
if not math.isfinite(result):
raise ValueError(f"ControlNet {field} must be finite")
return result
strength = finite_number(strength, "strength")
start_percent = finite_number(start_percent, "start_percent")
end_percent = finite_number(end_percent, "end_percent")
if not -10.0 <= strength <= 10.0:
raise ValueError("ControlNet strength must be in [-10, 10]")
if not 0.0 <= start_percent <= end_percent <= 1.0:
raise ValueError(
"ControlNet percentages must satisfy "
"0 <= start_percent <= end_percent <= 1")
if strength == 0.0:
return positive, negative
rt = current_runtime()
source = await rt.refs.resolve(control_net)
positive_value = await rt.refs.resolve(positive)
negative_value = await rt.refs.resolve(negative)
pixels = await rt.refs.resolve(image)
vae_value = None if vae is None else await rt.refs.resolve(vae)
control_hint = pixels.movedim(-1, 1)
control_nets: dict[Any, Any] = {}
outputs = []
for conditioning in (positive_value, negative_value):
result = []
for item in conditioning:
metadata = item[1].copy()
previous = metadata.get("control")
if previous in control_nets:
applied = control_nets[previous]
else:
applied = source.copy().set_cond_hint(
control_hint, strength,
(start_percent, end_percent), vae=vae_value,
extra_concat=[])
applied.set_previous_controlnet(previous)
control_nets[previous] = applied
metadata["control"] = applied
metadata["control_apply_to_uncond"] = False
result.append([item[0], metadata])
outputs.append(result)
return (
CondRef._wrap(await rt.refs.create(
"CONDITIONING", outputs[0])),
CondRef._wrap(await rt.refs.create(
"CONDITIONING", outputs[1])),
) # type: ignore[return-value]
async def _controlnet_apply_advanced(
self, control_net: "ControlNetRef", positive: "CondRef",
negative: "CondRef", image: "ImageRef", strength: float = 1.0,
start_percent: float = 0.0, end_percent: float = 1.0,
vae: Optional["VaeRef"] = None,
mask: Optional["MaskRef"] = None,
timestep_keyframe: Optional["TimestepKeyframeRef"] = None,
weights: Optional["ControlNetWeightsRef"] = None,
) -> tuple["CondRef", "CondRef"]:
import math
def finite_number(value: Any, field: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"Advanced ControlNet {field} must be a number")
result = float(value)
if not math.isfinite(result):
raise ValueError(
f"Advanced ControlNet {field} must be finite")
return result
strength = finite_number(strength, "strength")
start_percent = finite_number(start_percent, "start_percent")
end_percent = finite_number(end_percent, "end_percent")
if not -10.0 <= strength <= 10.0:
raise ValueError(
"Advanced ControlNet strength must be in [-10, 10]")
if not 0.0 <= start_percent <= end_percent <= 1.0:
raise ValueError(
"Advanced ControlNet percentages must satisfy "
"0 <= start_percent <= end_percent <= 1")
if strength == 0.0:
return positive, negative
rt = current_runtime()
source = await rt.refs.resolve(control_net)
positive_value = await rt.refs.resolve(positive)
negative_value = await rt.refs.resolve(negative)
pixels = await rt.refs.resolve(image)
vae_value = None if vae is None else await rt.refs.resolve(vae)
mask_value = None if mask is None else await rt.refs.resolve(mask)
keyframe_value = (
None if timestep_keyframe is None
else await rt.refs.resolve(timestep_keyframe))
weights_value = (
None if weights is None else await rt.refs.resolve(weights))
advanced = _advanced_control_module("control")
control_hint = pixels.movedim(-1, 1)
control_nets: dict[Any, Any] = {}
outputs = []
for conditioning in (positive_value, negative_value):
result = []
for item in conditioning:
metadata = item[1].copy()
previous = metadata.get("control")
if previous in control_nets:
applied = control_nets[previous]
else:
applied = advanced.convert_to_advanced(
source.copy()).set_cond_hint(
control_hint, strength,
(start_percent, end_percent), vae_value)
if advanced.is_advanced_controlnet(applied):
applied.disarm()
wrapper_type = advanced.AbstractPreprocWrapper
is_wrapper = isinstance(control_hint, wrapper_type)
if (applied.allow_condhint_latents
and not applied.require_vae
and not is_wrapper):
raise TypeError(
f"{type(applied).__name__} requires a "
"preprocessed ControlNet image")
if (not applied.allow_condhint_latents and is_wrapper
and not applied.postpone_condhint_latents_check):
raise TypeError(
f"{type(applied).__name__} requires a normal image")
if (applied.require_vae
and not (applied.allow_condhint_latents
and is_wrapper)
and vae_value is None):
raise ValueError(
f"{type(applied).__name__} requires a VAE")
if keyframe_value is not None:
applied.set_timestep_keyframes(keyframe_value)
if weights_value is not None:
applied.weights_override = weights_value
applied.verify_all_weights()
if mask_value is not None:
effect_mask = mask_value.clone()
if len(effect_mask.shape) < 3:
effect_mask = effect_mask.unsqueeze(0)
applied.set_cond_hint_mask(effect_mask)
applied.set_previous_controlnet(previous)
control_nets[previous] = applied
metadata["control"] = applied
metadata["control_apply_to_uncond"] = False
result.append([item[0], metadata])
outputs.append(result)
return (
CondRef._wrap(await rt.refs.create(
"CONDITIONING", outputs[0])),
CondRef._wrap(await rt.refs.create(
"CONDITIONING", outputs[1])),
) # type: ignore[return-value]
async def _controlnet_compile(
self, control_net: "ControlNetRef", backend: str = "inductor",
mode: str = "default", fullgraph: bool = False,
) -> "ControlNetRef":
import torch
options = self._checked_compile_options(backend, mode, fullgraph)
rt = current_runtime()
source = await rt.refs.resolve(control_net)
control_model = getattr(source, "control_model", None)
if control_model is None or not callable(getattr(source, "copy", None)):
raise TypeError("CONTROL_NET has no compilable control model")
result = source.copy()
result.control_model = torch.compile(control_model, **options)
return ControlNetRef._wrap(await rt.refs.create(
"CONTROL_NET", result)) # type: ignore[return-value]
async def _style_model_apply(
self, style_model: "StyleModelRef",
clip_vision_output: "ClipVisionOutputRef",
conditioning: "CondRef", strength: float = 1.0,
) -> "CondRef":
import torch
rt = current_runtime()
model = await rt.refs.resolve(style_model)
vision = await rt.refs.resolve(clip_vision_output)
source = await rt.refs.resolve(conditioning)
style = model.get_cond(vision).flatten(
start_dim=0, end_dim=1).unsqueeze(dim=0)
style = float(strength) * style
result = [[torch.cat((item[0], style), dim=1), item[1].copy()]
for item in source]
return CondRef._wrap(await rt.refs.create(
"CONDITIONING", result)) # type: ignore[return-value]
async def _inpaint_model_inpaint(
self, inpaint_model: "InpaintModelRef",
image: "ImageRef", mask: "MaskRef",
) -> "ImageRef":
import torch
import comfy.model_management
rt = current_runtime()
bundle = await rt.refs.resolve(inpaint_model)
pixels = await rt.refs.resolve(image)
mask_value = await rt.refs.resolve(mask)
if bundle.get("secure_kind") != "image_inpaint.big-lama":
raise ValueError("unknown image inpaint model")
if pixels.ndim != 4 or pixels.shape[-1] < 3 or not 1 <= len(pixels) <= 4096:
raise ValueError("inpaint images must be a non-empty BHWC batch")
if mask_value.ndim == 4 and mask_value.shape[1] == 1:
mask_value = mask_value[:, 0]
elif mask_value.ndim == 4 and mask_value.shape[-1] == 1:
mask_value = mask_value[..., 0]
if mask_value.ndim != 3:
raise ValueError("inpaint masks must be a BHW batch")
height, width = map(int, pixels.shape[1:3])
if tuple(mask_value.shape[-2:]) != (height, width):
raise ValueError("inpaint image and mask dimensions must match")
if len(mask_value) not in (1, len(pixels)):
raise ValueError("inpaint image and mask batches must match")
if min(height, width) < 16 or height % 8 or width % 8:
raise ValueError(
"Big-LaMa image dimensions must be multiples of 8 and at least 16")
if len(pixels) * height * width > 67_108_864:
raise ValueError("inpaint batch exceeds 67108864 pixels")
model = bundle["model"]
model_lock = bundle["lock"]
device = comfy.model_management.get_torch_device()
offload_device = comfy.model_management.unet_offload_device()
source = pixels[..., :3].movedim(-1, 1).to(
device=device, dtype=torch.float32)
holes = mask_value.unsqueeze(1).to(
device=device, dtype=torch.float32)
if len(holes) == 1 and len(source) > 1:
holes = holes.expand(len(source), -1, -1, -1)
source = source.clamp(0.0, 1.0)
holes = holes.clamp(0.0, 1.0)
with model_lock:
model.to(device=device, dtype=torch.float32)
try:
result = model(source, holes)
result = result.movedim(1, -1).clamp(0.0, 1.0)
result = result.detach().to(device="cpu", dtype=torch.float32)
finally:
model.to(offload_device)
comfy.model_management.soft_empty_cache()
return ImageRef._wrap(await rt.refs.create(
"IMAGE", result)) # type: ignore[return-value]
async def _background_removal_mask(
self, background_model: "BackgroundRemovalModelRef",
image: "ImageRef",
) -> "MaskRef":
import torch
rt = current_runtime()
bundle = await rt.refs.resolve(background_model)
pixels = await rt.refs.resolve(image)
if (
not isinstance(bundle, dict)
or bundle.get("secure_kind") != "background_removal.comfy"
):
raise ValueError("unknown background-removal model")
if (
not isinstance(pixels, torch.Tensor)
or pixels.ndim != 4
or pixels.shape[-1] < 3
or not 1 <= len(pixels) <= 4096
):
raise ValueError(
"background removal requires a non-empty BHWC image batch")
height, width = map(int, pixels.shape[1:3])
if height <= 0 or width <= 0 or len(pixels) * height * width > 67_108_864:
raise ValueError("background-removal image batch is too large")
with bundle["lock"]:
result = bundle["model"].encode_image(pixels[..., :3])
if (
not isinstance(result, torch.Tensor)
or result.ndim != 3
or tuple(result.shape) != (len(pixels), height, width)
):
raise RuntimeError(
"background-removal model returned an invalid mask")
return MaskRef._wrap(await rt.refs.create(
"MASK", result.clamp(0.0, 1.0)
)) # type: ignore[return-value]
async def _brushnet_apply(
self, brushnet: "BrushNetRef", model: "ModelRef", vae: "VaeRef",
image: "ImageRef", mask: "MaskRef", positive: "CondRef",
negative: "CondRef", scale: float = 1.0, start_step: int = 0,
end_step: int = 10000,
) -> tuple["ModelRef", "CondRef", "CondRef", "LatentRef"]:
import math
import torch
import nodes
if isinstance(scale, bool) or not isinstance(scale, (int, float)):
raise TypeError("BrushNet scale must be a number")
scale = float(scale)
if not math.isfinite(scale) or not 0.0 <= scale <= 10.0:
raise ValueError("BrushNet scale must be finite and in [0, 10]")
if (isinstance(start_step, bool) or not isinstance(start_step, int)
or isinstance(end_step, bool) or not isinstance(end_step, int)):
raise TypeError("BrushNet start_step and end_step must be integers")
if not 0 <= start_step <= end_step <= 10000:
raise ValueError(
"BrushNet steps must satisfy 0 <= start_step <= end_step <= 10000")
rt = current_runtime()
brushnet_value = await rt.refs.resolve(brushnet)
model_value = await rt.refs.resolve(model)
vae_value = await rt.refs.resolve(vae)
pixels = await rt.refs.resolve(image)
mask_value = await rt.refs.resolve(mask)
positive_value = await rt.refs.resolve(positive)
negative_value = await rt.refs.resolve(negative)
if (not isinstance(brushnet_value, dict)
or brushnet_value.get("brushnet") is None
or brushnet_value.get("PP") is not False):
raise TypeError("BRUSHNET_MODEL is not a host-loaded BrushNet model")
if (not isinstance(pixels, torch.Tensor)
or pixels.ndim not in {3, 4} or pixels.shape[-1] < 3):
raise ValueError("BrushNet image must be an HWC or BHWC tensor")
if not isinstance(mask_value, torch.Tensor) or mask_value.ndim not in {2, 3}:
raise ValueError("BrushNet mask must be an HW or BHW tensor")
image_height, image_width = map(int, pixels.shape[-3:-1])
if tuple(map(int, mask_value.shape[-2:])) != (image_height, image_width):
raise ValueError("BrushNet image and mask dimensions must match")
image_batch = 1 if pixels.ndim == 3 else int(pixels.shape[0])
mask_batch = 1 if mask_value.ndim == 2 else int(mask_value.shape[0])
if (image_batch < 1 or image_batch > 4096
or mask_batch < 1 or mask_batch > 4096
or image_batch * image_height * image_width > 67_108_864):
raise ValueError("BrushNet image batch is too large")
if not isinstance(positive_value, list) or not isinstance(negative_value, list):
raise TypeError("BrushNet conditioning must be host conditioning lists")
node_class = getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get("BrushNet")
if node_class is None:
raise RuntimeError(
"BrushNet application requires the host-installed canonical "
"ComfyUI-BrushNet extension")
result = await asyncio.to_thread(
node_class().model_update,
model_value, vae_value, pixels, mask_value, brushnet_value,
positive_value, negative_value, scale, start_step, end_step,
)
if not isinstance(result, (tuple, list)) or len(result) != 4:
raise RuntimeError("the canonical BrushNet node returned invalid outputs")
patched, positive_out, negative_out, latent = result
if (patched is None or not isinstance(positive_out, list)
or not isinstance(negative_out, list)
or not isinstance(latent, dict)
or not isinstance(latent.get("samples"), torch.Tensor)):
raise RuntimeError("the canonical BrushNet node returned invalid outputs")
return (
ModelRef._wrap(await rt.refs.create("MODEL", patched)),
CondRef._wrap(await rt.refs.create("CONDITIONING", positive_out)),
CondRef._wrap(await rt.refs.create("CONDITIONING", negative_out)),
LatentRef._wrap(await rt.refs.create("LATENT", latent)),
) # type: ignore[return-value]
async def _powerpaint_apply(
self, powerpaint: "PowerPaintRef", model: "ModelRef", vae: "VaeRef",
image: "ImageRef", mask: "MaskRef", positive: "CondRef",
negative: "CondRef", fitting: float = 1.0,
function: str = "text guided", scale: float = 1.0,
start_step: int = 0, end_step: int = 10000,
save_memory: str = "none",
) -> tuple["ModelRef", "CondRef", "CondRef", "LatentRef"]:
import math
import torch
import nodes
for label, value, minimum, maximum in (
("fitting", fitting, 0.3, 1.0),
("scale", scale, 0.0, 10.0),
):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"PowerPaint {label} must be a number")
if not math.isfinite(float(value)) or not minimum <= float(value) <= maximum:
raise ValueError(
f"PowerPaint {label} must be finite and in "
f"[{minimum}, {maximum}]")
fitting = float(fitting)
scale = float(scale)
functions = {
"text guided", "shape guided", "object removal",
"context aware", "image outpainting",
}
if function not in functions:
raise ValueError(f"unknown PowerPaint function {function!r}")
if save_memory not in {"none", "auto", "max"}:
raise ValueError(f"unknown PowerPaint save_memory {save_memory!r}")
if (isinstance(start_step, bool) or not isinstance(start_step, int)
or isinstance(end_step, bool) or not isinstance(end_step, int)):
raise TypeError("PowerPaint start_step and end_step must be integers")
if not 0 <= start_step <= end_step <= 10000:
raise ValueError(
"PowerPaint steps must satisfy "
"0 <= start_step <= end_step <= 10000")
rt = current_runtime()
bundle = await rt.refs.resolve(powerpaint)
model_value = await rt.refs.resolve(model)
vae_value = await rt.refs.resolve(vae)
pixels = await rt.refs.resolve(image)
mask_value = await rt.refs.resolve(mask)
positive_value = await rt.refs.resolve(positive)
negative_value = await rt.refs.resolve(negative)
if (not isinstance(bundle, dict)
or bundle.get("secure_kind") != "powerpaint.pipeline"
or not isinstance(bundle.get("powerpaint"), dict)
or bundle["powerpaint"].get("PP") is not True
or bundle.get("clip") is None):
raise TypeError(
"POWERPAINT_MODEL is not a host-loaded PowerPaint pipeline")
if (not isinstance(pixels, torch.Tensor)
or pixels.ndim not in {3, 4} or pixels.shape[-1] < 3):
raise ValueError("PowerPaint image must be an HWC or BHWC tensor")
if not isinstance(mask_value, torch.Tensor) or mask_value.ndim not in {2, 3}:
raise ValueError("PowerPaint mask must be an HW or BHW tensor")
height, width = map(int, pixels.shape[-3:-1])
if tuple(map(int, mask_value.shape[-2:])) != (height, width):
raise ValueError("PowerPaint image and mask dimensions must match")
image_batch = 1 if pixels.ndim == 3 else int(pixels.shape[0])
mask_batch = 1 if mask_value.ndim == 2 else int(mask_value.shape[0])
if (image_batch < 1 or image_batch > 4096
or mask_batch < 1 or mask_batch > 4096
or image_batch * height * width > 67_108_864):
raise ValueError("PowerPaint image batch is too large")
if not isinstance(positive_value, list) or not isinstance(negative_value, list):
raise TypeError("PowerPaint conditioning must be host conditioning lists")
node_class = getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get("PowerPaint")
if node_class is None:
raise RuntimeError(
"PowerPaint application requires the host-installed canonical "
"ComfyUI-BrushNet extension")
result = await asyncio.to_thread(
node_class().model_update,
model_value, vae_value, pixels, mask_value,
bundle["powerpaint"], bundle["clip"], positive_value,
negative_value, fitting, function, scale, start_step, end_step,
save_memory,
)
if not isinstance(result, (tuple, list)) or len(result) != 4:
raise RuntimeError(
"the canonical PowerPaint node returned invalid outputs")
patched, positive_out, negative_out, latent = result
if (patched is None or not isinstance(positive_out, list)
or not isinstance(negative_out, list)
or not isinstance(latent, dict)
or not isinstance(latent.get("samples"), torch.Tensor)):
raise RuntimeError(
"the canonical PowerPaint node returned invalid outputs")
return (
ModelRef._wrap(await rt.refs.create("MODEL", patched)),
CondRef._wrap(await rt.refs.create("CONDITIONING", positive_out)),
CondRef._wrap(await rt.refs.create("CONDITIONING", negative_out)),
LatentRef._wrap(await rt.refs.create("LATENT", latent)),
) # type: ignore[return-value]
async def _transparent_vae_decoder_decode(
self, decoder: "TransparentVaeDecoderRef", latent: "LatentRef",
image: "ImageRef", frames: int = 1, sub_batch_size: int = 16,
) -> tuple["ImageRef", "MaskRef"]:
import torch
if isinstance(frames, bool) or not isinstance(frames, int):
raise TypeError("transparent decoder frames must be an integer")
if not 1 <= frames <= 3:
raise ValueError("transparent decoder frames must be in [1, 3]")
if (
isinstance(sub_batch_size, bool)
or not isinstance(sub_batch_size, int)
):
raise TypeError(
"transparent decoder sub_batch_size must be an integer")
if not 1 <= sub_batch_size <= 64:
raise ValueError(
"transparent decoder sub_batch_size must be in [1, 64]")
rt = current_runtime()
entry = await rt.refs.resolve(decoder)
latent_value = await rt.refs.resolve(latent)
pixels = await rt.refs.resolve(image)
samples = (
latent_value.get("samples")
if isinstance(latent_value, dict) else None)
if not isinstance(entry, _TransparentVaeDecoderEntry):
raise TypeError(
"TRANSPARENT_VAE_DECODER is not a host-loaded decoder")
if (
not isinstance(samples, torch.Tensor)
or samples.ndim != 4
or not isinstance(pixels, torch.Tensor)
or pixels.ndim != 4
or pixels.shape[-1] < 3
or not 1 <= len(pixels) <= 64
or len(samples) != len(pixels)
):
raise ValueError(
"transparent decoding needs matching BCHW latent and BHWC "
"image batches")
height, width = map(int, pixels.shape[1:3])
if (
height <= 0
or width <= 0
or height % 64
or width % 64
or len(pixels) * height * width > 67_108_864
):
raise ValueError(
"transparent decoder image dimensions must be multiples of "
"64 within the bounded batch limit")
if len(pixels) % frames:
raise ValueError(
"transparent decoder batch must be divisible by frames")
def decode_selected():
selected_pixels = pixels[::frames, ..., :3].movedim(-1, 1)
selected_samples = samples[::frames]
decoded = []
with entry.lock:
for start in range(0, len(selected_samples), sub_batch_size):
decoded.append(entry.decoder.decode_pixel(
selected_pixels[start:start + sub_batch_size],
selected_samples[start:start + sub_batch_size],
))
result = torch.cat(decoded, dim=0)
if (
result.ndim != 4
or result.shape[1] < 4
or tuple(result.shape[2:]) != (height, width)
):
raise RuntimeError(
"canonical transparent VAE decoder returned an invalid "
"pixel tensor")
result = result.movedim(1, -1)
decoded_rgb = result[..., 1:4].clamp(0.0, 1.0)
alpha = (1.0 - result[..., 0]).clamp(0.0, 1.0)
full_alpha = torch.ones(
(len(pixels), height, width),
dtype=alpha.dtype, device=alpha.device)
full_rgb = pixels[..., :3].to(decoded_rgb).clone()
full_rgb[::frames] = decoded_rgb
full_alpha[::frames] = alpha
rgba = torch.cat((full_rgb, full_alpha.unsqueeze(-1)), dim=-1)
return rgba, alpha
rgba, alpha = await asyncio.to_thread(decode_selected)
return (
ImageRef._wrap(await rt.refs.create("IMAGE", rgba)),
MaskRef._wrap(await rt.refs.create("MASK", alpha)),
) # type: ignore[return-value]
async def _clipseg_segment(
self, clipseg: "ClipSegRef", images: "ImageRef", text: str,
threshold: float = 0.5, binary_mask: bool = True,
combine_mask: bool = False, use_accelerator: bool = True,
blur_sigma: float = 0.0, previous_mask: Optional["MaskRef"] = None,
invert: bool = False, image_background_level: float = 0.5,
) -> tuple["MaskRef", "ImageRef"]:
from contextlib import nullcontext
import numpy as np
import torch
import torch.nn.functional as functional
import torchvision.transforms as transforms
from PIL import Image
import comfy.model_management
threshold = float(threshold)
blur_sigma = float(blur_sigma)
background = float(image_background_level)
if not 0.0 <= threshold <= 10.0:
raise ValueError("CLIPSeg threshold must be in [0, 10]")
if not 0.0 <= blur_sigma <= 100.0:
raise ValueError("CLIPSeg blur_sigma must be in [0, 100]")
if not 0.0 <= background <= 1.0:
raise ValueError("CLIPSeg image background level must be in [0, 1]")
rt = current_runtime()
bundle = await rt.refs.resolve(clipseg)
pixels = await rt.refs.resolve(images)
previous = (None if previous_mask is None
else await rt.refs.resolve(previous_mask))
model = bundle["model"]
processor = bundle["processor"]
model_lock = bundle.get("lock")
offload_device = comfy.model_management.unet_offload_device()
device = (comfy.model_management.get_torch_device()
if use_accelerator else torch.device("cpu"))
dtype = comfy.model_management.unet_dtype()
with model_lock if model_lock is not None else nullcontext():
model.to(dtype).to(device)
try:
height, width = pixels.shape[1:3]
source = pixels.to(device)
autocast = (
dtype != torch.float32
and not comfy.model_management.is_device_mps(device))
scope = (torch.autocast(
comfy.model_management.get_autocast_device(device), dtype=dtype)
if autocast else nullcontext())
with scope, torch.inference_mode():
pil_images = [Image.fromarray(np.clip(
255.0 * image.cpu().numpy().squeeze(), 0, 255
).astype(np.uint8)) for image in source]
inputs = processor(
text=[str(text)] * len(source), images=pil_images,
return_tensors="pt", padding=True, truncation=True,
max_length=77)
inputs = {
key: value.to(device) for key, value in inputs.items()
}
outputs = model(**inputs)
mask = torch.sigmoid(outputs.logits)
minimum, maximum = mask.amin(), mask.amax()
scale = (maximum - minimum).clamp_min(
torch.finfo(mask.dtype).eps)
mask = (mask - minimum) / scale
mask = torch.where(
mask > threshold, mask,
torch.tensor(0, dtype=torch.float, device=mask.device))
if mask.ndim == 2:
mask = mask.unsqueeze(0)
mask = functional.interpolate(
mask.unsqueeze(1), size=(height, width), mode="nearest"
).squeeze(1)
finally:
model.to(offload_device)
if binary_mask:
mask = (mask > 0).float()
if blur_sigma > 0:
kernel_size = 6 * int(blur_sigma) + 1
mask = transforms.GaussianBlur(
kernel_size=(kernel_size, kernel_size),
sigma=(blur_sigma, blur_sigma))(mask)
if combine_mask:
mask = torch.max(mask, dim=0)[0].unsqueeze(0).repeat(
len(source), 1, 1)
comfy.model_management.soft_empty_cache()
if previous is not None:
if previous.shape != mask.shape:
previous = functional.interpolate(
previous.unsqueeze(1), size=(height, width), mode="nearest"
).squeeze(1)
mask = mask + previous.to(device)
mask = torch.clamp(mask, min=0.0, max=1.0)
if invert:
mask = 1 - mask
result_image = torch.clamp(
source * mask.unsqueeze(-1)
+ (1 - mask.unsqueeze(-1)) * background,
min=0.0, max=1.0).cpu().float()
result_mask = mask.cpu().float()
mask_ref = MaskRef._wrap(await rt.refs.create("MASK", result_mask))
image_ref = ImageRef._wrap(await rt.refs.create("IMAGE", result_image))
return mask_ref, image_ref # type: ignore[return-value]
async def _clipseg_predict_mask(
self, clipseg: "ClipSegRef", images: "ImageRef", text: str,
use_accelerator: bool = True,
) -> "MaskRef":
"""Run CLIPSeg while leaving thresholding/post-processing to the node."""
from contextlib import nullcontext
import numpy as np
import torch
import comfy.model_management
text = str(text)
if len(text) > 32768:
raise ValueError("CLIPSeg text exceeds 32768 characters")
rt = current_runtime()
bundle = await rt.refs.resolve(clipseg)
pixels = await rt.refs.resolve(images)
if pixels.ndim != 4 or pixels.shape[-1] < 3:
raise ValueError("CLIPSeg images must be a non-empty BHWC batch")
if not 1 <= len(pixels) <= 4096:
raise ValueError("CLIPSeg batch size must be in [1, 4096]")
model = bundle["model"]
processor = bundle["processor"]
model_lock = bundle.get("lock")
offload_device = comfy.model_management.unet_offload_device()
if use_accelerator:
device = comfy.model_management.get_torch_device()
dtype = comfy.model_management.unet_dtype()
else:
device = torch.device("cpu")
dtype = torch.float32
with model_lock if model_lock is not None else nullcontext():
model.to(dtype).to(device)
try:
autocast = (
dtype != torch.float32
and not comfy.model_management.is_device_mps(device)
)
outputs = []
for image in pixels:
array = np.clip(
image.detach().cpu().numpy() * 255.0, 0, 255
).astype(np.uint8)
inputs = processor(
text=text, images=[array], return_tensors="pt",
padding=True, truncation=True, max_length=77)
inputs = {
key: value.to(device) for key, value in inputs.items()
}
scope = (
torch.autocast(
comfy.model_management.get_autocast_device(device),
dtype=dtype,
)
if autocast else nullcontext()
)
with scope, torch.inference_mode():
prediction = model(**inputs).logits.unsqueeze(1)
outputs.append(torch.sigmoid(prediction[0][0]))
result = torch.stack(outputs, dim=0).cpu().float()
finally:
model.to(offload_device)
comfy.model_management.soft_empty_cache()
return MaskRef._wrap(
await rt.refs.create("MASK", result)
) # type: ignore[return-value]
async def _image_classifier_classify(
self, classifier: "ImageClassifierRef", images: "ImageRef",
use_accelerator: bool = True, top_k: int = 5,
) -> list[list[dict[str, Any]]]:
from contextlib import nullcontext
import numpy as np
import torch
from PIL import Image
import comfy.model_management
top_k = int(top_k)
if not 1 <= top_k <= 1000:
raise ValueError("image classifier top_k must be in [1, 1000]")
rt = current_runtime()
bundle = await rt.refs.resolve(classifier)
pixels = await rt.refs.resolve(images)
if pixels.ndim != 4 or pixels.shape[-1] < 3:
raise ValueError("classifier images must be a non-empty BHWC batch")
if not 1 <= len(pixels) <= 4096:
raise ValueError("classifier batch size must be in [1, 4096]")
labels = tuple(bundle["labels"])
if not labels:
raise ValueError("image classifier has no labels")
model = bundle["model"]
processor = bundle["processor"]
model_lock = bundle.get("lock")
offload_device = comfy.model_management.unet_offload_device()
if use_accelerator:
device = comfy.model_management.get_torch_device()
dtype = comfy.model_management.unet_dtype()
else:
device = torch.device("cpu")
dtype = torch.float32
with model_lock if model_lock is not None else nullcontext():
model.to(dtype).to(device)
try:
source = [Image.fromarray(np.clip(
image.detach().cpu().numpy()[..., :3] * 255.0,
0, 255,
).astype(np.uint8), mode="RGB") for image in pixels]
inputs = processor(images=source, return_tensors="pt")
inputs = {
key: value.to(device) for key, value in inputs.items()
}
autocast = (
dtype != torch.float32
and not comfy.model_management.is_device_mps(device)
)
scope = (
torch.autocast(
comfy.model_management.get_autocast_device(device),
dtype=dtype,
) if autocast else nullcontext()
)
with scope, torch.inference_mode():
logits = model(**inputs).logits
scores = torch.softmax(logits.float(), dim=-1)
finally:
model.to(offload_device)
if scores.ndim != 2 or scores.shape[1] != len(labels):
raise RuntimeError("image classifier returned an invalid score shape")
count = min(top_k, len(labels))
values, indices = torch.topk(scores.cpu(), count, dim=-1)
return [[
{"label": labels[int(index)], "score": float(score)}
for score, index in zip(row_scores, row_indices)
] for row_scores, row_indices in zip(values, indices)]
async def _image_classifier_predict_scores(
self, classifier: "ImageClassifierRef", images: "ImageRef",
) -> "ClassifierScoresRef":
import numpy as np
import torch
from PIL import Image
rt = current_runtime()
bundle = await rt.refs.resolve(classifier)
if (not isinstance(bundle, dict)
or bundle.get("secure_kind") != "image_classifier.onnx"):
raise TypeError(
"predict_scores requires a validated ONNX image classifier")
pixels = await rt.refs.resolve(images)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or pixels.shape[-1] < 3 or not 1 <= len(pixels) <= 64):
raise ValueError(
"ONNX classifier images must be a 1-64 item BHWC RGB batch")
height, width = map(int, pixels.shape[1:3])
if (height <= 0 or width <= 0
or height * width * len(pixels) > 268_435_456
or not bool(torch.isfinite(pixels[..., :3]).all())):
raise ValueError("ONNX classifier image values are invalid")
target_height = int(bundle["input_height"])
target_width = int(bundle["input_width"])
resampling = {
"nearest": Image.Resampling.NEAREST,
"bilinear": Image.Resampling.BILINEAR,
"bicubic": Image.Resampling.BICUBIC,
"lanczos": Image.Resampling.LANCZOS,
}[bundle["resize_filter"]]
pad = tuple(
int(round(float(value) * 255.0)) for value in bundle["pad_color"])
mean = np.asarray(bundle["mean"], dtype=np.float32)
std = np.asarray(bundle["std"], dtype=np.float32)
input_scale = float(bundle["input_scale"])
def infer() -> np.ndarray:
rows = []
with bundle["lock"]:
for frame in pixels:
source_array = np.clip(
frame.detach().cpu().numpy()[..., :3] * 255.0,
0, 255,
).astype(np.uint8)
source = Image.fromarray(source_array)
if bundle["resize_mode"] == "fit_pad":
ratio = min(
target_width / source.width,
target_height / source.height,
)
resized_size = (
max(1, int(source.width * ratio)),
max(1, int(source.height * ratio)),
)
resized = source.resize(resized_size, resampling)
prepared = Image.new(
"RGB", (target_width, target_height), pad)
prepared.paste(resized, (
(target_width - resized_size[0]) // 2,
(target_height - resized_size[1]) // 2,
))
else:
prepared = source.resize(
(target_width, target_height), resampling)
array = np.asarray(prepared, dtype=np.float32)
array = array * (input_scale / 255.0)
if bundle["channel_order"] == "BGR":
array = array[..., ::-1]
array = (array - mean) / std
if bundle["input_layout"] == "NCHW":
array = np.transpose(array, (2, 0, 1))
model_input = np.ascontiguousarray(
array[None, ...], dtype=np.float32)
output = bundle["session"].run(
[bundle["output_name"]],
{bundle["input_name"]: model_input},
)[0]
output = np.asarray(output)
if output.shape != (1, int(bundle["class_count"])):
raise RuntimeError(
"ONNX classifier returned an invalid score shape")
row = output[0].astype(np.float32, copy=False)
if bundle["activation"] == "sigmoid":
row = 1.0 / (1.0 + np.exp(-np.clip(row, -80, 80)))
elif bundle["activation"] == "softmax":
shifted = row - np.max(row)
exponent = np.exp(shifted)
row = exponent / np.sum(exponent)
if not np.isfinite(row).all():
raise RuntimeError(
"ONNX classifier returned non-finite scores")
rows.append(row.astype(np.float32, copy=True))
return np.stack(rows, axis=0)
scores = await asyncio.to_thread(infer)
value = {"secure_kind": "classifier_scores.v1", "scores": scores}
return ClassifierScoresRef._wrap(await rt.refs.create(
"CLASSIFIER_SCORES", value)) # type: ignore[return-value]
async def _classifier_scores_shape(
self, scores: "ClassifierScoresRef",
) -> tuple[int, int]:
import numpy as np
bundle = await current_runtime().refs.resolve(scores)
value = bundle.get("scores") if isinstance(bundle, dict) else None
if (not isinstance(value, np.ndarray) or value.ndim != 2
or not 1 <= value.shape[0] <= 64
or not 1 <= value.shape[1] <= 16_384):
raise TypeError("CLASSIFIER_SCORES handle is invalid")
return int(value.shape[0]), int(value.shape[1])
async def _classifier_scores_select_above(
self, scores: "ClassifierScoresRef", batch_index: int,
start: int, end: int, threshold: float,
offset: int = 0, limit: int = 512,
) -> dict[str, Any]:
import math
import numpy as np
bundle = await current_runtime().refs.resolve(scores)
value = bundle.get("scores") if isinstance(bundle, dict) else None
if (not isinstance(value, np.ndarray) or value.ndim != 2
or not 1 <= value.shape[0] <= 64
or not 1 <= value.shape[1] <= 16_384):
raise TypeError("CLASSIFIER_SCORES handle is invalid")
batch_index = int(batch_index)
start = int(start)
end = int(end)
offset = int(offset)
limit = int(limit)
threshold = float(threshold)
if not 0 <= batch_index < value.shape[0]:
raise ValueError("classifier score batch index is invalid")
if not 0 <= start <= end <= value.shape[1]:
raise ValueError("classifier score class range is invalid")
if not math.isfinite(threshold) or abs(threshold) > 1_000_000:
raise ValueError("classifier score threshold is invalid")
if not 0 <= offset <= value.shape[1]:
raise ValueError("classifier score page offset is invalid")
if not 1 <= limit <= 512:
raise ValueError("classifier score page limit must be in [1, 512]")
matches = np.flatnonzero(value[batch_index, start:end] > threshold)
matches = matches.astype(np.int64, copy=False) + start
selected = matches[offset:offset + limit]
next_offset = offset + len(selected)
return {
"items": [{
"index": int(index),
"score": float(value[batch_index, index]),
} for index in selected],
"next_offset": (
next_offset if next_offset < len(matches) else None),
}
async def _semantic_segmentation_mask(
self, segmentation: "SemanticSegmentationRef", image: "ImageRef",
classes: list[int],
) -> "MaskRef":
"""Run a fixed SegFormer and union the requested semantic classes.
This primitive deliberately stops at class selection. Packs retain
ownership of label menus, alpha composition, cropping, and workflow
behavior.
"""
import torch
import torch.nn.functional as functional
import comfy.model_management
if not isinstance(classes, (list, tuple)):
raise TypeError("semantic segmentation classes must be a list")
if not classes or len(classes) > 64:
raise ValueError(
"semantic segmentation needs between 1 and 64 classes")
rt = current_runtime()
entry = await rt.refs.resolve(segmentation)
if not isinstance(entry, _SegformerEntry):
raise TypeError(
"SEMANTIC_SEGMENTATION_MODEL is not a SegFormer model")
selected = []
for value in classes:
if (isinstance(value, bool) or not isinstance(value, int)
or not 0 <= value < entry.num_labels):
raise ValueError(
"semantic segmentation class IDs must match the model")
if value not in selected:
selected.append(value)
pixels = await rt.refs.resolve(image)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or pixels.shape[-1] < 3 or not 1 <= len(pixels) <= 64):
raise ValueError(
"semantic segmentation needs a non-empty BHWC RGB batch")
height, width = map(int, pixels.shape[1:3])
if (height <= 0 or width <= 0
or height * width * len(pixels) > 268_435_456):
raise ValueError(
"semantic segmentation image dimensions are invalid")
if not bool(torch.isfinite(pixels[..., :3]).all()):
raise ValueError(
"semantic segmentation images must contain finite values")
device = comfy.model_management.get_torch_device()
offload_device = comfy.model_management.unet_offload_device()
mean = torch.tensor(
(0.485, 0.456, 0.406), device=device,
dtype=torch.float32).view(1, 3, 1, 1)
std = torch.tensor(
(0.229, 0.224, 0.225), device=device,
dtype=torch.float32).view(1, 3, 1, 1)
masks = []
with entry.lock:
entry.model.to(device=device, dtype=torch.float32)
try:
for frame in pixels:
source = frame[..., :3].movedim(-1, 0).unsqueeze(0)
source = source.to(device=device, dtype=torch.float32)
source = functional.interpolate(
source, size=(512, 512), mode="bilinear",
align_corners=False)
source = (source - mean) / std
logits = entry.model(pixel_values=source).logits
if (not isinstance(logits, torch.Tensor)
or logits.ndim != 4
or logits.shape[0] != 1
or logits.shape[1] != entry.num_labels):
raise RuntimeError(
"SegFormer returned an invalid logits shape")
logits = functional.interpolate(
logits, size=(height, width), mode="bilinear",
align_corners=False)
labels = logits.argmax(dim=1)[0]
mask = torch.zeros_like(labels, dtype=torch.bool)
for class_id in selected:
mask |= labels == class_id
masks.append(mask.detach().cpu().float())
finally:
entry.model.to(offload_device)
comfy.model_management.soft_empty_cache()
return MaskRef._wrap(await rt.refs.create(
"MASK", torch.stack(masks, dim=0)
)) # type: ignore[return-value]
async def _matting_refine(
self, matting: "MattingModelRef", image: "ImageRef",
trimap: "MaskRef", max_megapixels: float = 2.0,
) -> "MaskRef":
import math
import torch
import torch.nn.functional as functional
import comfy.model_management
max_megapixels = float(max_megapixels)
if not math.isfinite(max_megapixels) or not 0.1 <= max_megapixels <= 1024.0:
raise ValueError("matting max_megapixels must be in [0.1, 1024]")
rt = current_runtime()
entry = await rt.refs.resolve(matting)
if not isinstance(entry, _VitMatteEntry):
raise TypeError("MATTING_MODEL is not a ViTMatte model")
pixels = await rt.refs.resolve(image)
trimap_value = await rt.refs.resolve(trimap)
if (
not isinstance(pixels, torch.Tensor)
or pixels.ndim != 4
or pixels.shape[-1] < 3
or not 1 <= len(pixels) <= 64
):
raise ValueError("matting requires a non-empty BHWC RGB batch")
if trimap_value.ndim == 4 and trimap_value.shape[1] == 1:
trimap_value = trimap_value[:, 0]
elif trimap_value.ndim == 4 and trimap_value.shape[-1] == 1:
trimap_value = trimap_value[..., 0]
if not isinstance(trimap_value, torch.Tensor) or trimap_value.ndim != 3:
raise ValueError("matting trimaps must be a BHW mask batch")
height, width = map(int, pixels.shape[1:3])
if (
tuple(trimap_value.shape[-2:]) != (height, width)
or len(trimap_value) not in (1, len(pixels))
):
raise ValueError("matting image and trimap dimensions must match")
if (
height <= 0
or width <= 0
or height * width * len(pixels) > 268_435_456
or not bool(torch.isfinite(pixels[..., :3]).all())
or not bool(torch.isfinite(trimap_value).all())
):
raise ValueError("matting inputs are invalid or too large")
if len(trimap_value) == 1 and len(pixels) > 1:
trimap_value = trimap_value.expand(len(pixels), -1, -1)
limit = max_megapixels * 1_048_576.0
if height * width > limit:
ratio = width / height
target_width = max(1, int(math.sqrt(ratio * limit)))
target_height = max(1, int(target_width / ratio))
else:
target_height, target_width = height, width
device = comfy.model_management.get_torch_device()
offload_device = comfy.model_management.unet_offload_device()
source = pixels[..., :3].movedim(-1, 1).to(
device=device, dtype=torch.float32).clamp(0.0, 1.0)
source_trimap = trimap_value.unsqueeze(1).to(
device=device, dtype=torch.float32).clamp(0.0, 1.0)
if (target_height, target_width) != (height, width):
source = functional.interpolate(
source, size=(target_height, target_width),
mode="bilinear", align_corners=False)
source_trimap = functional.interpolate(
source_trimap, size=(target_height, target_width),
mode="bilinear", align_corners=False)
values = torch.cat((source * 2.0 - 1.0, source_trimap), dim=1)
pad_height = (-target_height) % 32
pad_width = (-target_width) % 32
if pad_height or pad_width:
values = functional.pad(values, (0, pad_width, 0, pad_height))
with entry.lock:
entry.model.to(device=device, dtype=torch.float32)
try:
alpha = entry.model(pixel_values=values).alphas
if (
not isinstance(alpha, torch.Tensor)
or alpha.ndim != 4
or alpha.shape[:2] != (len(pixels), 1)
):
raise RuntimeError("ViTMatte returned an invalid alpha mask")
alpha = alpha[:, 0, :target_height, :target_width]
if (target_height, target_width) != (height, width):
alpha = functional.interpolate(
alpha.unsqueeze(1), size=(height, width),
mode="bilinear", align_corners=False)[:, 0]
result = alpha.detach().to(
device="cpu", dtype=torch.float32).clamp(0.0, 1.0)
finally:
entry.model.to(offload_device)
comfy.model_management.soft_empty_cache()
return MaskRef._wrap(await rt.refs.create(
"MASK", result)) # type: ignore[return-value]
async def _vqa_answer(
self, vqa: "VqaModelRef", image: "ImageRef", question: str,
max_new_tokens: int = 32,
) -> str:
import torch
import torch.nn.functional as functional
import comfy.model_management
question = str(question).strip()
if not question or len(question) > 4096:
raise ValueError("VQA questions must contain 1..4096 characters")
if (
isinstance(max_new_tokens, bool)
or not isinstance(max_new_tokens, int)
or not 1 <= max_new_tokens <= 128
):
raise ValueError("VQA max_new_tokens must be in [1, 128]")
rt = current_runtime()
value = await rt.refs.resolve(vqa)
if not isinstance(value, _VqaModelValue):
raise TypeError("VQA_MODEL is not a fixed BLIP model")
pixels = await rt.refs.resolve(image)
if (
not isinstance(pixels, torch.Tensor)
or pixels.ndim != 4
or pixels.shape[0] != 1
or pixels.shape[-1] < 3
):
raise ValueError("VQA requires exactly one BHWC RGB image")
height, width = map(int, pixels.shape[1:3])
if (
height <= 0
or width <= 0
or height * width > 67_108_864
or not bool(torch.isfinite(pixels[..., :3]).all())
):
raise ValueError("VQA image dimensions are invalid")
if value.device == "cuda" and torch.cuda.is_available():
device = comfy.model_management.get_torch_device()
else:
device = torch.device("cpu")
dtype = (
torch.float16
if value.precision == "fp16" and device.type != "cpu"
else torch.float32
)
source = pixels[..., :3].movedim(-1, 1).to(
device=device, dtype=dtype).clamp(0.0, 1.0)
source = functional.interpolate(
source, size=(384, 384), mode="bicubic",
align_corners=False, antialias=True)
mean = torch.tensor(
(0.48145466, 0.4578275, 0.40821073),
device=device, dtype=dtype).view(1, 3, 1, 1)
std = torch.tensor(
(0.26862954, 0.26130258, 0.27577711),
device=device, dtype=dtype).view(1, 3, 1, 1)
source = (source - mean) / std
encoded = value.entry.tokenizer(
question, return_tensors="pt", truncation=True, max_length=512)
input_ids = encoded["input_ids"].to(device)
attention_mask = encoded["attention_mask"].to(device)
offload_device = comfy.model_management.unet_offload_device()
with value.entry.lock:
value.entry.model.to(device=device, dtype=dtype)
try:
tokens = value.entry.model.generate(
input_ids=input_ids,
attention_mask=attention_mask,
pixel_values=source,
max_new_tokens=max_new_tokens,
)
if (
not isinstance(tokens, torch.Tensor)
or tokens.ndim != 2
or tokens.shape[0] != 1
):
raise RuntimeError("BLIP VQA returned invalid tokens")
answer = value.entry.tokenizer.decode(
tokens[0].detach().cpu().tolist(),
skip_special_tokens=True,
).strip()
finally:
value.entry.model.to(offload_device)
comfy.model_management.soft_empty_cache()
return answer
async def _onnx_detector_detect(
self, detector: "OnnxDetectorRef", image: "ImageRef",
) -> list[dict[str, Any]]:
from contextlib import nullcontext
import math
import numpy as np
import torch
rt = current_runtime()
bundle = await rt.refs.resolve(detector)
if (not isinstance(bundle, dict)
or bundle.get("secure_kind") != "onnx.object_detector"):
raise TypeError(
"ONNX_DETECTOR is not a trusted object-detector bundle")
pixels = await rt.refs.resolve(image)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or pixels.shape[0] != 1 or pixels.shape[-1] < 3):
raise ValueError("ONNX detection requires one BHWC RGB image")
height, width = map(int, pixels.shape[1:3])
if height <= 0 or width <= 0 or height * width > 268_435_456:
raise ValueError("ONNX detector image dimensions are invalid")
source = np.ascontiguousarray(
pixels[0, ..., :3].detach().cpu().numpy()[..., ::-1] * 255.0,
dtype=np.float32,
)
source -= np.asarray((103.939, 116.779, 123.68), dtype=np.float32)
model = bundle["model"]
model_lock = bundle.get("lock")
with model_lock if model_lock is not None else nullcontext():
model.setInput(source[None, ...])
outputs = model.forward(model.getUnconnectedOutLayersNames())
if isinstance(outputs, np.ndarray):
outputs = [outputs]
arrays = [np.asarray(output) for output in outputs]
labels = next(
(value for value in arrays if np.issubdtype(
value.dtype, np.integer) and value.size), None)
boxes = next(
(value for value in arrays
if value.ndim >= 2 and value.shape[-1] == 4 and value.size),
None,
)
scores = next(
(value for value in arrays
if np.issubdtype(value.dtype, np.floating)
and value is not boxes and value.size
and (value.ndim <= 2 or value.shape[-1] == 1)),
None,
)
if labels is None or scores is None or boxes is None:
raise RuntimeError(
"ONNX detector must return integer labels, scores, and xyxy boxes")
labels = labels.reshape(-1)
scores = scores.reshape(-1)
boxes = boxes.reshape(-1, 4)
count = min(len(labels), len(scores), len(boxes))
invalid = np.flatnonzero(labels[:count] == -1)
if len(invalid):
count = int(invalid[0])
if count > 4096:
raise RuntimeError("ONNX detector returned more than 4096 objects")
result = []
for label, score, box in zip(
labels[:count], scores[:count], boxes[:count], strict=True,
):
values = [float(value) for value in box]
confidence = float(score)
if not math.isfinite(confidence) or not all(
math.isfinite(value) for value in values
):
raise RuntimeError("ONNX detector returned non-finite values")
result.append({
"label": int(label),
"score": confidence,
"box": values,
})
return result
async def _object_detector_detect(
self, detector: "ObjectDetectorRef", image: "ImageRef",
threshold: float = 0.5, class_name: str = "all",
max_detections: int = 100,
) -> list[list[dict[str, Any]]]:
import torch
from comfy.ldm.rt_detr.rtdetr_v4 import COCO_CLASSES
from comfy_extras.nodes_rtdetr import detect
if not 0.0 <= threshold <= 1.0:
raise ValueError("object-detector threshold must be in [0, 1]")
if class_name != "all" and class_name not in COCO_CLASSES:
raise ValueError("object-detector class_name is not a COCO class")
if not 1 <= max_detections <= 4096:
raise ValueError("object-detector max_detections must be in [1, 4096]")
rt = current_runtime()
bundle = await rt.refs.resolve(detector)
if (not isinstance(bundle, dict)
or bundle.get("secure_kind") != "object_detector.rt_detr"):
raise TypeError(
"OBJECT_DETECTOR is not a trusted RT-DETR bundle")
pixels = await rt.refs.resolve(image)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or pixels.shape[-1] < 3 or not 1 <= pixels.shape[0] <= 64):
raise ValueError(
"object detection requires a non-empty BHWC RGB batch")
return detect(
bundle["model"], pixels[..., :3], threshold, class_name,
max_detections)
async def _ipadapter_apply(
self,
pipeline: "IpAdapterRef",
model: "ModelRef",
image: "ImageRef",
negative_image: Optional["ImageRef"] = None,
attn_mask: Optional["MaskRef"] = None,
style_image: Optional["ImageRef"] = None,
composition_image: Optional["ImageRef"] = None,
weight: float = 0.7,
weight_type: str = "channel penalty",
start_percent: float = 0.0,
end_percent: float = 1.0,
combine_embeds: str = "concat",
weight_faceidv2: float = 1.0,
embeds_scaling: str = "V only",
unfold_batch: bool = False,
layer_weights: Optional[str] = None,
weight_style: float = 1.0,
weight_composition: float = 1.0,
expand_style: bool = False,
) -> "ModelRef":
"""Apply one fixed IP-Adapter operation on the trusted plane.
This is intentionally an integration boundary, not an Impact detailer
implementation. It accepts an opaque pipeline produced by a trusted
host node and invokes the canonical IPAdapterAdvanced operation. The
guest remains responsible for SEGS traversal, crop choice, and chains.
"""
import math
import nodes
import torch
weight = float(weight)
start_percent = float(start_percent)
end_percent = float(end_percent)
weight_faceidv2 = float(weight_faceidv2)
weight_style = float(weight_style)
weight_composition = float(weight_composition)
if not all(math.isfinite(value) for value in (
weight, start_percent, end_percent, weight_faceidv2,
weight_style, weight_composition,
)):
raise ValueError("IP-Adapter numeric parameters must be finite")
if not -1.0 <= weight <= 3.0:
raise ValueError("IP-Adapter weight must be in [-1, 3]")
if not 0.0 <= start_percent <= end_percent <= 1.0:
raise ValueError(
"IP-Adapter percentages must satisfy 0 <= start <= end <= 1")
if not -1.0 <= weight_faceidv2 <= 5.0:
raise ValueError("IP-Adapter FaceID v2 weight must be in [-1, 5]")
if not -1.0 <= weight_style <= 5.0:
raise ValueError("IP-Adapter style weight must be in [-1, 5]")
if not -1.0 <= weight_composition <= 5.0:
raise ValueError("IP-Adapter composition weight must be in [-1, 5]")
if weight_type not in {
"original", "linear", "channel penalty", "ease in", "ease out",
"ease in-out", "reverse in-out", "weak input", "weak output",
"weak middle", "strong middle", "style transfer", "composition",
"strong style transfer", "style and composition",
"style transfer precise", "composition precise",
}:
raise ValueError("unsupported IP-Adapter weight type")
if combine_embeds not in {
"concat", "add", "subtract", "average", "norm average",
}:
raise ValueError("unsupported IP-Adapter embedding combination")
if embeds_scaling not in {
"V only", "K+V", "K+V w/ C penalty",
"K+mean(V) w/ C penalty",
}:
raise ValueError("unsupported IP-Adapter embedding scaling")
if type(unfold_batch) is not bool:
raise TypeError("IP-Adapter unfold_batch must be a bool")
if type(expand_style) is not bool:
raise TypeError("IP-Adapter expand_style must be a bool")
if (layer_weights is not None
and (not isinstance(layer_weights, str)
or len(layer_weights) > 16_384)):
raise ValueError("IP-Adapter layer weights are invalid")
rt = current_runtime()
pipe_value = await rt.refs.resolve(pipeline)
if not _is_ipadapter_pipe(pipe_value):
raise TypeError(
"IPADAPTER_PIPE is not a host-created IP-Adapter pipeline")
model_value = await rt.refs.resolve(model)
pixels = await rt.refs.resolve(image)
negative_pixels = (
None if negative_image is None
else await rt.refs.resolve(negative_image)
)
style_pixels = (
None if style_image is None
else await rt.refs.resolve(style_image)
)
composition_pixels = (
None if composition_image is None
else await rt.refs.resolve(composition_image)
)
mask_value = (
None if attn_mask is None
else await rt.refs.resolve(attn_mask)
)
for name, value in (
("image", pixels),
("negative_image", negative_pixels),
("style_image", style_pixels),
("composition_image", composition_pixels),
):
if value is None:
continue
if (not isinstance(value, torch.Tensor) or value.ndim != 4
or value.shape[-1] < 3 or value.shape[0] < 1
or value.shape[0] > 4096):
raise ValueError(
f"IP-Adapter {name} must be a bounded BHWC image batch")
height, width = map(int, value.shape[1:3])
if (height <= 0 or width <= 0
or height * width * int(value.shape[0]) > 268_435_456):
raise ValueError(f"IP-Adapter {name} dimensions are invalid")
if mask_value is not None:
if (not isinstance(mask_value, torch.Tensor)
or mask_value.ndim not in (2, 3)
or mask_value.numel() <= 0
or mask_value.numel() > 268_435_456):
raise ValueError("IP-Adapter attention mask must be bounded HW/BHW")
if isinstance(pipe_value, dict):
ipadapter = pipe_value["ipadapter"]
clip_vision = pipe_value["clip_vision"]
insightface = None
patched_model = model_value
else:
ipadapter, _unused, clip_vision, insightface, lora_loader = pipe_value
if not callable(lora_loader):
raise TypeError(
"IPADAPTER_PIPE does not contain a host LoRA loader")
patched_model = lora_loader(model_value)
node_name = "IPAdapterBatch" if unfold_batch else "IPAdapterAdvanced"
node_class = getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get(node_name)
if node_class is None:
if getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get(
"IPAdapterApply") is not None:
raise RuntimeError(
"ComfyUI IPAdapter Plus is installed but outdated; "
"IPAdapterAdvanced is required")
raise RuntimeError(
"IP-Adapter application requires the host-installed "
"ComfyUI IPAdapter Plus extension")
result = node_class().apply_ipadapter(
model=patched_model,
ipadapter=ipadapter,
weight=weight,
weight_type=weight_type,
start_at=start_percent,
end_at=end_percent,
combine_embeds=combine_embeds,
clip_vision=clip_vision,
image=pixels,
image_negative=negative_pixels,
attn_mask=mask_value,
insightface=insightface,
weight_faceidv2=weight_faceidv2,
embeds_scaling=embeds_scaling,
layer_weights=layer_weights,
image_style=style_pixels,
image_composition=composition_pixels,
weight_style=weight_style,
weight_composition=weight_composition,
expand_style=expand_style,
)
if not isinstance(result, (tuple, list)) or not result:
raise RuntimeError("IPAdapterAdvanced returned no model")
return ModelRef._wrap(await rt.refs.create(
"MODEL", result[0])) # type: ignore[return-value]
async def _ipadapter_apply_tiled(
self,
pipeline: "IpAdapterRef",
model: "ModelRef",
image: "ImageRef",
negative_image: Optional["ImageRef"] = None,
attn_mask: Optional["MaskRef"] = None,
weight: float = 0.7,
weight_type: str = "linear",
start_percent: float = 0.0,
end_percent: float = 1.0,
combine_embeds: str = "concat",
embeds_scaling: str = "V only",
sharpening: float = 0.0,
unfold_batch: bool = False,
) -> tuple["ModelRef", "ImageRef", "MaskRef"]:
"""Invoke the host extension's canonical tiled operation."""
import math
import nodes
import torch
weight = float(weight)
start_percent = float(start_percent)
end_percent = float(end_percent)
sharpening = float(sharpening)
if not all(math.isfinite(value) for value in (
weight, start_percent, end_percent, sharpening,
)):
raise ValueError("IP-Adapter tiled parameters must be finite")
if not -1.0 <= weight <= 3.0:
raise ValueError("IP-Adapter weight must be in [-1, 3]")
if not 0.0 <= start_percent <= end_percent <= 1.0:
raise ValueError(
"IP-Adapter percentages must satisfy 0 <= start <= end <= 1")
if not 0.0 <= sharpening <= 1.0:
raise ValueError("IP-Adapter sharpening must be in [0, 1]")
if weight_type not in {
"linear", "ease in", "ease out", "ease in-out",
"reverse in-out", "weak input", "weak output", "weak middle",
"strong middle", "style transfer", "composition",
"strong style transfer", "style and composition",
"style transfer precise", "composition precise",
}:
raise ValueError("unsupported IP-Adapter weight type")
if combine_embeds not in {
"concat", "add", "subtract", "average", "norm average",
}:
raise ValueError("unsupported IP-Adapter embedding combination")
if embeds_scaling not in {
"V only", "K+V", "K+V w/ C penalty",
"K+mean(V) w/ C penalty",
}:
raise ValueError("unsupported IP-Adapter embedding scaling")
if type(unfold_batch) is not bool:
raise TypeError("IP-Adapter unfold_batch must be a bool")
rt = current_runtime()
pipe_value = await rt.refs.resolve(pipeline)
if not _is_ipadapter_pipe(pipe_value):
raise TypeError(
"IPADAPTER_PIPE is not a host-created IP-Adapter pipeline")
model_value = await rt.refs.resolve(model)
pixels = await rt.refs.resolve(image)
negative_pixels = (
None if negative_image is None
else await rt.refs.resolve(negative_image)
)
mask_value = (
None if attn_mask is None
else await rt.refs.resolve(attn_mask)
)
for name, value in (("image", pixels),
("negative_image", negative_pixels)):
if value is None:
continue
if (not isinstance(value, torch.Tensor) or value.ndim != 4
or value.shape[-1] < 3 or value.shape[0] < 1
or value.shape[0] > 4096):
raise ValueError(
f"IP-Adapter {name} must be a bounded BHWC image batch")
height, width = map(int, value.shape[1:3])
if (height <= 0 or width <= 0
or height * width * int(value.shape[0]) > 268_435_456):
raise ValueError(f"IP-Adapter {name} dimensions are invalid")
if mask_value is not None and (
not isinstance(mask_value, torch.Tensor)
or mask_value.ndim not in (2, 3)
or mask_value.numel() <= 0
or mask_value.numel() > 268_435_456
):
raise ValueError("IP-Adapter attention mask must be bounded HW/BHW")
if isinstance(pipe_value, dict):
ipadapter = pipe_value["ipadapter"]
clip_vision = pipe_value["clip_vision"]
patched_model = model_value
else:
ipadapter, _unused, clip_vision, _insightface, lora_loader = pipe_value
if not callable(lora_loader):
raise TypeError(
"IPADAPTER_PIPE does not contain a host LoRA loader")
patched_model = lora_loader(model_value)
node_name = (
"IPAdapterTiledBatch" if unfold_batch else "IPAdapterTiled")
node_class = getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get(node_name)
if node_class is None:
raise RuntimeError(
"tiled IP-Adapter application requires the host-installed "
"ComfyUI IPAdapter Plus extension")
result = node_class().apply_tiled(
model=patched_model,
ipadapter=ipadapter,
image=pixels,
weight=weight,
weight_type=weight_type,
start_at=start_percent,
end_at=end_percent,
sharpening=sharpening,
combine_embeds=combine_embeds,
image_negative=negative_pixels,
attn_mask=mask_value,
clip_vision=clip_vision,
embeds_scaling=embeds_scaling,
)
if (not isinstance(result, (tuple, list)) or len(result) < 3
or not isinstance(result[1], torch.Tensor)
or not isinstance(result[2], torch.Tensor)):
raise RuntimeError("IPAdapterTiled returned invalid outputs")
return (
ModelRef._wrap(await rt.refs.create("MODEL", result[0])),
ImageRef._wrap(await rt.refs.create("IMAGE", result[1])),
MaskRef._wrap(await rt.refs.create("MASK", result[2])),
)
async def _ipadapter_encode(
self,
pipeline: "IpAdapterRef",
image: "ImageRef",
weight: float = 1.0,
mask: Optional["MaskRef"] = None,
) -> tuple["IpAdapterEmbedsRef", "IpAdapterEmbedsRef"]:
import math
import nodes
import torch
weight = float(weight)
if not math.isfinite(weight) or not -1.0 <= weight <= 3.0:
raise ValueError("IP-Adapter embedding weight must be in [-1, 3]")
rt = current_runtime()
pipe_value = await rt.refs.resolve(pipeline)
if not _is_ipadapter_pipe(pipe_value):
raise TypeError(
"IPADAPTER_PIPE is not a host-created IP-Adapter pipeline")
pixels = await rt.refs.resolve(image)
mask_value = None if mask is None else await rt.refs.resolve(mask)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or pixels.shape[-1] < 3 or not 1 <= pixels.shape[0] <= 4096
or pixels.shape[1] <= 0 or pixels.shape[2] <= 0
or pixels.shape[0] * pixels.shape[1] * pixels.shape[2]
> 268_435_456):
raise ValueError(
"IP-Adapter encoding requires a bounded BHWC image batch")
if mask_value is not None and (
not isinstance(mask_value, torch.Tensor)
or mask_value.ndim not in (2, 3)
or not 0 < mask_value.numel() <= 268_435_456
):
raise ValueError("IP-Adapter mask must be bounded HW/BHW")
if isinstance(pipe_value, dict):
ipadapter = pipe_value["ipadapter"]
clip_vision = pipe_value["clip_vision"]
else:
ipadapter, _unused, clip_vision, _insightface, _loader = pipe_value
node_class = getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get(
"IPAdapterEncoder")
if node_class is None:
raise RuntimeError(
"IP-Adapter encoding requires the host-installed "
"ComfyUI IPAdapter Plus extension")
result = node_class().encode(
ipadapter=ipadapter,
image=pixels,
weight=weight,
mask=mask_value,
clip_vision=clip_vision,
)
if (not isinstance(result, (tuple, list)) or len(result) < 2
or any(not isinstance(value, torch.Tensor) for value in result[:2])
or any(not 0 < value.numel() <= 268_435_456
for value in result[:2])):
raise RuntimeError("IPAdapterEncoder returned invalid embeddings")
return (
IpAdapterEmbedsRef._wrap(await rt.refs.create(
"IPADAPTER_EMBEDS", result[0])),
IpAdapterEmbedsRef._wrap(await rt.refs.create(
"IPADAPTER_EMBEDS", result[1])),
)
async def _ipadapter_embeds_combine(
self,
first: "IpAdapterEmbedsRef",
others: list["IpAdapterEmbedsRef"],
method: str = "concat",
) -> "IpAdapterEmbedsRef":
import nodes
import torch
if method not in {
"concat", "add", "subtract", "average", "norm average",
"max", "min",
}:
raise ValueError("unsupported IP-Adapter embedding combination")
if not isinstance(others, list) or len(others) > 4:
raise ValueError("at most five IP-Adapter embeddings may be combined")
refs = [first, *others]
if any(not isinstance(ref, IpAdapterEmbedsRef) for ref in refs):
raise TypeError("IP-Adapter embedding combination needs typed refs")
rt = current_runtime()
values = [await rt.refs.resolve(ref) for ref in refs]
if any(
not isinstance(value, torch.Tensor)
or value.ndim < 2
or not 0 < value.numel() <= 268_435_456
for value in values
):
raise ValueError("IP-Adapter embeddings are invalid")
node_class = getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get(
"IPAdapterCombineEmbeds")
if node_class is None:
raise RuntimeError(
"IP-Adapter embedding combination requires the "
"host-installed ComfyUI IPAdapter Plus extension")
padded = values + [None] * (5 - len(values))
result = node_class().batch(
embed1=padded[0],
embed2=padded[1],
embed3=padded[2],
embed4=padded[3],
embed5=padded[4],
method=method,
)
if (not isinstance(result, (tuple, list)) or not result
or not isinstance(result[0], torch.Tensor)
or not 0 < result[0].numel() <= 268_435_456):
raise RuntimeError(
"IPAdapterCombineEmbeds returned invalid embeddings")
return IpAdapterEmbedsRef._wrap(await rt.refs.create(
"IPADAPTER_EMBEDS", result[0])) # type: ignore[return-value]
async def _ipadapter_apply_embeds(
self,
pipeline: "IpAdapterRef",
model: "ModelRef",
positive: "IpAdapterEmbedsRef",
negative: Optional["IpAdapterEmbedsRef"] = None,
attn_mask: Optional["MaskRef"] = None,
weight: float = 1.0,
weight_type: str = "linear",
start_percent: float = 0.0,
end_percent: float = 1.0,
embeds_scaling: str = "V only",
) -> "ModelRef":
import math
import nodes
import torch
weight = float(weight)
start_percent = float(start_percent)
end_percent = float(end_percent)
if not all(math.isfinite(value) for value in (
weight, start_percent, end_percent,
)):
raise ValueError("IP-Adapter embedding parameters must be finite")
if not -1.0 <= weight <= 3.0:
raise ValueError("IP-Adapter weight must be in [-1, 3]")
if not 0.0 <= start_percent <= end_percent <= 1.0:
raise ValueError(
"IP-Adapter percentages must satisfy 0 <= start <= end <= 1")
if weight_type not in {
"linear", "ease in", "ease out", "ease in-out",
"reverse in-out", "weak input", "weak output", "weak middle",
"strong middle", "style transfer", "composition",
"strong style transfer", "style and composition",
"style transfer precise", "composition precise",
}:
raise ValueError("unsupported IP-Adapter weight type")
if embeds_scaling not in {
"V only", "K+V", "K+V w/ C penalty",
"K+mean(V) w/ C penalty",
}:
raise ValueError("unsupported IP-Adapter embedding scaling")
if not isinstance(positive, IpAdapterEmbedsRef) or (
negative is not None
and not isinstance(negative, IpAdapterEmbedsRef)
):
raise TypeError("IP-Adapter application needs typed embedding refs")
rt = current_runtime()
pipe_value = await rt.refs.resolve(pipeline)
if not _is_ipadapter_pipe(pipe_value):
raise TypeError(
"IPADAPTER_PIPE is not a host-created IP-Adapter pipeline")
model_value = await rt.refs.resolve(model)
pos_value = await rt.refs.resolve(positive)
neg_value = None if negative is None else await rt.refs.resolve(negative)
mask_value = (
None if attn_mask is None else await rt.refs.resolve(attn_mask))
for value in (pos_value, neg_value):
if value is not None and (
not isinstance(value, torch.Tensor)
or value.ndim < 2
or not 0 < value.numel() <= 268_435_456
):
raise ValueError("IP-Adapter embeddings are invalid")
if mask_value is not None and (
not isinstance(mask_value, torch.Tensor)
or mask_value.ndim not in (2, 3)
or not 0 < mask_value.numel() <= 268_435_456
):
raise ValueError("IP-Adapter attention mask must be bounded HW/BHW")
if isinstance(pipe_value, dict):
ipadapter = pipe_value["ipadapter"]
clip_vision = pipe_value["clip_vision"]
patched_model = model_value
else:
ipadapter, _unused, clip_vision, _insightface, loader = pipe_value
if not callable(loader):
raise TypeError(
"IPADAPTER_PIPE does not contain a host LoRA loader")
patched_model = loader(model_value)
node_class = getattr(nodes, "NODE_CLASS_MAPPINGS", {}).get(
"IPAdapterEmbeds")
if node_class is None:
raise RuntimeError(
"IP-Adapter embedding application requires the "
"host-installed ComfyUI IPAdapter Plus extension")
result = node_class().apply_ipadapter(
model=patched_model,
ipadapter=ipadapter,
pos_embed=pos_value,
neg_embed=neg_value,
attn_mask=mask_value,
clip_vision=clip_vision,
weight=weight,
weight_type=weight_type,
start_at=start_percent,
end_at=end_percent,
embeds_scaling=embeds_scaling,
)
if not isinstance(result, (tuple, list)) or not result:
raise RuntimeError("IPAdapterEmbeds returned no model")
return ModelRef._wrap(await rt.refs.create(
"MODEL", result[0])) # type: ignore[return-value]
async def _image_preprocessor_apply(
self,
preprocessor: "ImagePreprocessorRef",
image: "ImageRef",
mask: Optional["MaskRef"] = None,
) -> "ImageRef":
"""Invoke one trusted provider object's image-to-image operation."""
import torch
rt = current_runtime()
provider = await rt.refs.resolve(preprocessor)
apply = getattr(provider, "apply", None)
if not callable(apply) or not _is_image_preprocessor(provider):
raise TypeError(
"IMAGE_PREPROCESSOR is not a recognized host provider")
pixels = await rt.refs.resolve(image)
mask_value = None if mask is None else await rt.refs.resolve(mask)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or pixels.shape[0] < 1 or pixels.shape[0] > 4096
or pixels.shape[-1] < 3):
raise ValueError(
"image preprocessing requires a bounded BHWC image batch")
height, width = map(int, pixels.shape[1:3])
if (height <= 0 or width <= 0
or height * width * int(pixels.shape[0]) > 268_435_456):
raise ValueError("image preprocessor input dimensions are invalid")
if mask_value is not None:
if (not isinstance(mask_value, torch.Tensor)
or mask_value.ndim not in {2, 3, 4}):
raise ValueError("image preprocessor mask has an invalid shape")
result = apply(pixels, mask_value)
if (not isinstance(result, torch.Tensor) or result.ndim != 4
or result.shape[0] < 1 or result.shape[0] > 4096
or result.shape[-1] < 3):
raise RuntimeError(
"image preprocessor returned an invalid image batch")
out_height, out_width = map(int, result.shape[1:3])
if (out_height <= 0 or out_width <= 0
or out_height * out_width * int(result.shape[0])
> 268_435_456):
raise RuntimeError(
"image preprocessor output dimensions are invalid")
return ImageRef._wrap(await rt.refs.create(
"IMAGE", result)) # type: ignore[return-value]
async def _sam_segment(
self, sam: "SamModelRef", image: "ImageRef",
boxes: list[Optional[list[float]]],
point_coords: Optional[list[list[list[float]]]] = None,
point_labels: Optional[list[list[int]]] = None,
multimask_output: bool = True,
) -> tuple["MaskRef", list[list[float]]]:
from contextlib import nullcontext
import math
import numpy as np
import torch
import comfy.model_management
from segment_anything import SamPredictor
rt = current_runtime()
bundle = await rt.refs.resolve(sam)
if (not isinstance(bundle, dict)
or bundle.get("secure_kind") != "sam.v1"):
raise TypeError("SAM_MODEL is not a trusted SAM v1 bundle")
pixels = await rt.refs.resolve(image)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or pixels.shape[0] != 1 or pixels.shape[-1] < 3):
raise ValueError("SAM segmentation requires one BHWC RGB image")
height, width = int(pixels.shape[1]), int(pixels.shape[2])
if (height <= 0 or width <= 0 or height * width > 268_435_456):
raise ValueError("SAM image dimensions are invalid or too large")
if not isinstance(boxes, (list, tuple)) or not 1 <= len(boxes) <= 1024:
raise ValueError("SAM boxes must contain 1 to 1024 queries")
if type(multimask_output) is not bool:
raise TypeError("SAM multimask_output must be a bool")
query_count = len(boxes)
if point_coords is None:
point_coords = [[] for _ in range(query_count)]
if point_labels is None:
point_labels = [[] for _ in range(query_count)]
if (not isinstance(point_coords, (list, tuple))
or not isinstance(point_labels, (list, tuple))
or len(point_coords) != query_count
or len(point_labels) != query_count):
raise ValueError("SAM point hints must match the box query count")
def finite_number(value: Any) -> bool:
return (type(value) in (int, float)
and math.isfinite(float(value)))
normalized_boxes = []
normalized_points = []
normalized_labels = []
total_points = 0
for box, points, labels in zip(boxes, point_coords, point_labels):
if box is None:
normalized_box = None
else:
if (not isinstance(box, (list, tuple)) or len(box) != 4
or not all(finite_number(value) for value in box)):
raise ValueError("each SAM box must be x1,y1,x2,y2 or null")
x1, y1, x2, y2 = (float(value) for value in box)
if not (0 <= x1 < x2 <= width and 0 <= y1 < y2 <= height):
raise ValueError("SAM boxes must be inside the image")
normalized_box = [x1, y1, x2, y2]
if (not isinstance(points, (list, tuple))
or not isinstance(labels, (list, tuple))
or len(points) != len(labels)):
raise ValueError("SAM points and labels must have equal lengths")
if len(points) > 4096:
raise ValueError("a SAM query may contain at most 4096 points")
query_points = []
query_labels = []
for point, label in zip(points, labels):
if (not isinstance(point, (list, tuple)) or len(point) != 2
or not all(finite_number(value) for value in point)):
raise ValueError("each SAM point must be an x,y pair")
x, y = float(point[0]), float(point[1])
if not (0 <= x < width and 0 <= y < height):
raise ValueError("SAM points must be inside the image")
if type(label) is not int or label not in (0, 1):
raise ValueError("SAM point labels must be 0 or 1")
query_points.append([x, y])
query_labels.append(label)
if normalized_box is None and not query_points:
raise ValueError("each SAM query needs a box or point hint")
total_points += len(query_points)
normalized_boxes.append(normalized_box)
normalized_points.append(query_points)
normalized_labels.append(query_labels)
if total_points > 65_536:
raise ValueError("SAM request contains too many point hints")
model = bundle["model"]
model_lock = bundle.get("lock")
device_mode = bundle["device_mode"]
device = (
torch.device("cpu") if device_mode == "CPU"
else comfy.model_management.get_torch_device()
)
source = np.clip(
pixels[0, ..., :3].detach().cpu().numpy() * 255.0,
0, 255,
).astype(np.uint8)
masks_by_query = []
scores_by_query = []
with model_lock if model_lock is not None else nullcontext():
model.to(device)
try:
predictor = SamPredictor(model)
with torch.inference_mode():
predictor.set_image(source, image_format="RGB")
for box, points, labels in zip(
normalized_boxes, normalized_points, normalized_labels,
):
masks, scores, _logits = predictor.predict(
point_coords=(
None if not points
else np.asarray(points, dtype=np.float32)),
point_labels=(
None if not labels
else np.asarray(labels, dtype=np.int64)),
box=(
None if box is None
else np.asarray(box, dtype=np.float32)),
multimask_output=multimask_output,
return_logits=False,
)
masks_by_query.append(torch.from_numpy(masks).float())
scores_by_query.append([
float(score) for score in np.asarray(scores).tolist()
])
finally:
if device_mode != "Prefer GPU":
model.to("cpu")
if device_mode == "AUTO":
comfy.model_management.soft_empty_cache()
output = torch.stack(masks_by_query, dim=0).cpu()
return (
MaskRef._wrap(await rt.refs.create("MASK", output)),
scores_by_query,
) # type: ignore[return-value]
async def _sam_segment_video(
self, sam: "SamModelRef", frames: "ImageRef",
boxes: list[list[float]],
) -> "MaskRef":
from contextlib import nullcontext
import math
import torch
import torch.nn.functional as functional
import comfy.model_management
rt = current_runtime()
bundle = await rt.refs.resolve(sam)
if (not isinstance(bundle, dict)
or bundle.get("secure_kind") != "sam.v2"):
raise TypeError("SAM_MODEL is not a trusted SAM2 bundle")
pixels = await rt.refs.resolve(frames)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or pixels.shape[-1] < 3 or pixels.shape[0] < 1):
raise ValueError("SAM2 requires a non-empty BHWC RGB video")
frame_count, height, width = map(int, pixels.shape[:3])
if (frame_count > 1024
or height <= 0 or width <= 0
or frame_count * height * width > 268_435_456):
raise ValueError("SAM2 video dimensions are invalid or too large")
if not isinstance(boxes, (list, tuple)) or not 1 <= len(boxes) <= 128:
raise ValueError("SAM2 boxes must contain 1 to 128 queries")
normalized_boxes = []
for box in boxes:
if (not isinstance(box, (list, tuple)) or len(box) != 4
or not all(type(value) in (int, float)
and math.isfinite(float(value))
for value in box)):
raise ValueError("each SAM2 box must be x1,y1,x2,y2")
x1, y1, x2, y2 = (float(value) for value in box)
if not (0 <= x1 < x2 <= width and 0 <= y1 < y2 <= height):
raise ValueError("SAM2 boxes must be inside the video frame")
normalized_boxes.append([x1, y1, x2, y2])
predictor = bundle["model"]
model_lock = bundle.get("lock")
device_mode = bundle["device_mode"]
device = (
torch.device("cpu") if device_mode == "CPU"
else comfy.model_management.get_torch_device()
)
image_size = int(predictor.image_size)
scale = min(image_size / width, image_size / height)
resized_width = max(1, min(image_size, int(width * scale)))
resized_height = max(1, min(image_size, int(height * scale)))
pad_left = (image_size - resized_width) // 2
pad_right = image_size - resized_width - pad_left
pad_top = (image_size - resized_height) // 2
pad_bottom = image_size - resized_height - pad_top
source = functional.interpolate(
pixels[..., :3].movedim(-1, 1).float(),
size=(resized_height, resized_width),
mode="bilinear", align_corners=False,
)
source = functional.pad(
source, (pad_left, pad_right, pad_top, pad_bottom))
mean = source.new_tensor((0.485, 0.456, 0.406))[None, :, None, None]
std = source.new_tensor((0.229, 0.224, 0.225))[None, :, None, None]
source = (source - mean) / std
adjusted_boxes = [[
box[0] * resized_width / width + pad_left,
box[1] * resized_height / height + pad_top,
box[2] * resized_width / width + pad_left,
box[3] * resized_height / height + pad_top,
] for box in normalized_boxes]
inference_state = None
with model_lock if model_lock is not None else nullcontext():
predictor.to(device)
try:
inference_state = {
"images": source,
"num_frames": frame_count,
"video_height": image_size,
"video_width": image_size,
"offload_video_to_cpu": True,
"offload_state_to_cpu": device_mode == "CPU",
"device": predictor.device,
"storage_device": (
torch.device("cpu") if device_mode == "CPU"
else predictor.device),
"point_inputs_per_obj": {},
"mask_inputs_per_obj": {},
"cached_features": {},
"constants": {},
"obj_id_to_idx": OrderedDict(),
"obj_idx_to_id": OrderedDict(),
"obj_ids": [],
"output_dict_per_obj": {},
"temp_output_dict_per_obj": {},
"frames_tracked_per_obj": {},
}
predictor._get_image_feature(
inference_state, frame_idx=0, batch_size=1)
for object_id, box in enumerate(adjusted_boxes):
center = [[
(box[0] + box[2]) / 2.0,
(box[1] + box[3]) / 2.0,
]]
predictor.add_new_points_or_box(
inference_state=inference_state,
frame_idx=0,
obj_id=object_id,
points=center,
labels=[1],
box=box,
)
logits: list[list[Optional[torch.Tensor]]] = [
[None] * frame_count for _ in adjusted_boxes
]
for frame_index, object_ids, masks in (
predictor.propagate_in_video(inference_state)
):
for mask_index, object_id in enumerate(object_ids):
logits[int(object_id)][int(frame_index)] = (
masks[mask_index, 0].detach().cpu())
if any(mask is None for item in logits for mask in item):
raise RuntimeError("SAM2 did not return every object frame")
output = torch.stack([
torch.stack(item) for item in logits
])
output = output[
:, :, pad_top:image_size - pad_bottom,
pad_left:image_size - pad_right,
]
output = functional.interpolate(
output.flatten(0, 1).unsqueeze(1),
size=(height, width), mode="bilinear",
align_corners=False,
).squeeze(1).unflatten(0, (len(adjusted_boxes), frame_count))
finally:
if inference_state is not None:
predictor.reset_state(inference_state)
if device_mode != "Prefer GPU":
predictor.to("cpu")
if device_mode == "AUTO":
comfy.model_management.soft_empty_cache()
return MaskRef._wrap(
await rt.refs.create("MASK", output.cpu().float())
) # type: ignore[return-value]
async def _upscale_model_upscale(
self, upscale_model: "UpscaleModelRef", images: "ImageRef",
per_batch: int = 16, downscale_ratio: float = 1.0,
downscale_method: str = "lanczos", precision: str = "float32",
tile_size: Optional[int] = None, channels_last: bool = False,
) -> "ImageRef":
import torch
import comfy.model_management
import comfy.utils
from comfy.utils import common_upscale
batch_size = int(per_batch)
ratio = float(downscale_ratio)
methods = {"nearest-exact", "bilinear", "area", "bicubic", "lanczos"}
dtypes = {
"float32": torch.float32,
"float16": torch.float16,
"bfloat16": torch.bfloat16,
}
if not 1 <= batch_size <= 4096:
raise ValueError("upscale per_batch must be in [1, 4096]")
if not 0.01 <= ratio <= 1.0:
raise ValueError("upscale downscale_ratio must be in [0.01, 1]")
if downscale_method not in methods:
raise ValueError(f"unknown upscale downscale method {downscale_method!r}")
if precision not in dtypes:
raise ValueError(f"unknown upscale precision {precision!r}")
initial_tile = None
if tile_size is not None:
initial_tile = int(tile_size)
if initial_tile != tile_size or not 0 <= initial_tile <= 2048:
raise ValueError("upscale tile_size must be in [0, 2048]")
initial_tile = 512 if initial_tile == 0 else max(initial_tile, 128)
if not isinstance(channels_last, bool):
raise TypeError("upscale channels_last must be a boolean")
rt = current_runtime()
model = await rt.refs.resolve(upscale_model)
pixels = await rt.refs.resolve(images)
parameter = next(model.model.parameters())
previous_device = parameter.device
previous_dtype = parameter.dtype
dtype = dtypes[precision]
device = comfy.model_management.get_torch_device()
outputs = []
try:
if initial_tile is None:
# Preserve the original operation for every existing caller.
model.to(device, dtype=dtype)
source = pixels.movedim(-1, -3).to(dtype)
progress = comfy.utils.ProgressBar(source.shape[0])
for start in range(0, source.shape[0], batch_size):
batch = model(source[start:start + batch_size].to(device))
outputs.append(batch.cpu())
progress.update(batch.shape[0])
else:
# WhiteRabbit's advanced node uses ComfyUI's tiled runner and
# autocast. The model itself stays in its admitted dtype.
from contextlib import nullcontext
model.to(device)
for parameter in model.model.parameters():
if parameter.device != device:
parameter.data = parameter.data.to(device)
if parameter.grad is not None:
parameter.grad.data = parameter.grad.data.to(device)
model.model.eval()
memory_required = int(
comfy.model_management.module_size(model.model))
scale = float(model.scale)
memory_required += int(
(512 * 512 * 3) * pixels.element_size()
* max(scale, 1.0) * 384.0)
memory_required += pixels.nelement() * pixels.element_size()
comfy.model_management.free_memory(memory_required, device)
for start in range(0, pixels.shape[0], batch_size):
current = pixels[start:start + batch_size].movedim(
-1, -3).to(device, non_blocking=True)
if channels_last and device.type == "cuda":
current = current.to(memory_format=torch.channels_last)
tile = initial_tile
while tile >= 128:
try:
steps = (
current.shape[0]
* comfy.utils.get_tiled_scale_steps(
current.shape[3], current.shape[2],
tile_x=tile, tile_y=tile, overlap=32)
)
progress = comfy.utils.ProgressBar(steps)
precision_context = nullcontext()
if device.type == "cuda" and precision != "float32":
precision_context = torch.autocast(
device_type="cuda", dtype=dtype)
with precision_context:
batch = comfy.utils.tiled_scale(
current,
model,
tile_x=tile,
tile_y=tile,
overlap=32,
upscale_amount=scale,
pbar=progress,
)
outputs.append(batch.cpu())
break
except Exception as error:
comfy.model_management.raise_non_oom(error)
tile //= 2
else:
raise RuntimeError(
"upscale model exhausted safe tile sizes after GPU OOM")
finally:
model.to(previous_device, dtype=previous_dtype)
output = torch.cat(outputs, dim=0).permute(0, 2, 3, 1).cpu().float()
if initial_tile is not None:
output = output.clamp(0.0, 1.0)
if ratio < 1.0:
height = int(output.shape[1] * ratio)
width = int(output.shape[2] * ratio)
output = common_upscale(
output.movedim(-1, 1), width, height,
downscale_method, "disabled").movedim(1, -1)
return ImageRef._wrap(await rt.refs.create("IMAGE", output)) # type: ignore[return-value]
async def _sampler_self_refine_video(
self, latent: Optional["LatentRef"],
stochastic_steps: list[dict[str, int]],
certain_percentage: float, uncertainty_threshold: float,
seed: int, verbose: bool = False,
) -> "SamplerRef":
import math
from comfy.samplers import KSAMPLER
if not isinstance(stochastic_steps, list) or len(stochastic_steps) > 1000:
raise TypeError("stochastic_steps must be a closed step list")
step_map = {}
for item in stochastic_steps:
if not isinstance(item, dict) or set(item) != {
"step", "anneal_steps"}:
raise TypeError("each stochastic step needs step and anneal_steps")
step = item["step"]
count = item["anneal_steps"]
if isinstance(step, bool) or not isinstance(step, int):
raise TypeError("stochastic step must be an integer")
if not 0 <= step <= 999:
raise ValueError("stochastic step must be in [0, 999]")
if isinstance(count, bool) or not isinstance(count, int):
raise TypeError("anneal_steps must be an integer")
if not 1 <= count <= 100:
raise ValueError("anneal_steps must be in [1, 100]")
if step in step_map:
raise ValueError("duplicate stochastic step")
step_map[step] = count
scalars = {}
for name, value in {
"certain_percentage": certain_percentage,
"uncertainty_threshold": uncertainty_threshold,
}.items():
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{name} must be a number")
value = float(value)
if not math.isfinite(value) or not 0.0 <= value <= 1.0:
raise ValueError(f"{name} must be finite and in [0, 1]")
scalars[name] = value
if isinstance(seed, bool) or not isinstance(seed, int):
raise TypeError("seed must be an integer")
if not 0 <= seed <= 0xffffffffffffffff:
raise ValueError("seed must be in [0, 2**64 - 1]")
if type(verbose) is not bool:
raise TypeError("verbose must be a boolean")
rt = current_runtime()
video_shape = None
if latent is not None:
if not isinstance(latent, LatentRef) or latent.kind != "LATENT":
raise TypeError("self-refine sampler needs a LATENT ref")
value = await rt.refs.resolve(latent)
if not isinstance(value, dict) or "samples" not in value:
raise TypeError("LATENT ref has no samples")
samples = value["samples"]
if not hasattr(samples, "shape"):
raise TypeError("LATENT samples have no shape")
video_shape = samples.shape
sampler = KSAMPLER(_sample_self_refine_video, {
"stochastic_step_map": step_map,
"certain_percentage": scalars["certain_percentage"],
"uncertainty_threshold": scalars["uncertainty_threshold"],
"verbose": verbose,
"video_shape": video_shape,
"seed": seed,
})
return SamplerRef._wrap(await rt.refs.create("SAMPLER", sampler)) # type: ignore[return-value]
async def _guider_scheduled_cfg(
self, model: "ModelRef", positive: "CondRef", negative: "CondRef",
cfg: float, start_percent: float = 0.0, end_percent: float = 1.0,
bounds: Optional[dict] = None,
) -> "GuiderRef":
import math
import torch
from comfy.samplers import CFGGuider, sampling_function
def checked_scalar(name, value, lower, upper):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{name} must be a number")
result = float(value)
if not math.isfinite(result) or not lower <= result <= upper:
raise ValueError(
f"{name} must be finite and in [{lower}, {upper}]")
return result
cfg_value = checked_scalar("cfg", cfg, 0.0, 100.0)
start_value = checked_scalar(
"start_percent", start_percent, 0.0, 1.0)
end_value = checked_scalar(
"end_percent", end_percent, 0.0, 1.0)
sigma_bounds = None
if bounds is not None:
if (
not isinstance(bounds, dict)
or set(bounds) != {"unit", "start", "end"}
or bounds.get("unit") != "sigma"
):
raise ValueError(
"scheduled CFG bounds must be an exact sigma range")
sigma_start = (
None if bounds["start"] is None else checked_scalar(
"bounds.start", bounds["start"], 0.0, 1_000_000.0)
)
sigma_end = (
None if bounds["end"] is None else checked_scalar(
"bounds.end", bounds["end"], 0.0, 1_000_000.0)
)
if (
sigma_start is not None
and sigma_end is not None
and sigma_start < sigma_end
):
raise ValueError(
"scheduled CFG sigma start must be at least its end")
sigma_bounds = (sigma_start, sigma_end)
if model.kind != "MODEL":
raise TypeError("scheduled CFG needs a MODEL ref")
if positive.kind != "CONDITIONING" or negative.kind != "CONDITIONING":
raise TypeError(
"scheduled CFG needs positive and negative CONDITIONING refs")
class ScheduledCFGGuider(CFGGuider):
def set_cfg(self, value, start, end, sigma_range):
self.cfg = value
self.start_percent = start
self.end_percent = end
self.sigma_bounds = sigma_range
def predict_noise(
self, x, timestep, model_options=None, seed=None,
):
if model_options is None:
model_options = {}
if self.sigma_bounds is not None:
if isinstance(timestep, torch.Tensor):
current_sigma = timestep.reshape(-1)[0]
else:
current_sigma = float(timestep)
sigma_start, sigma_end = self.sigma_bounds
if isinstance(current_sigma, torch.Tensor):
active = (
sigma_start is None
or bool(current_sigma <= current_sigma.new_tensor(sigma_start))
) and (
sigma_end is None
or bool(current_sigma > current_sigma.new_tensor(sigma_end))
)
else:
active = (
(sigma_start is None or current_sigma <= sigma_start)
and (sigma_end is None or current_sigma > sigma_end)
)
else:
steps = model_options[
"transformer_options"]["sample_sigmas"]
if isinstance(timestep, torch.Tensor):
timestep_value = timestep.reshape(-1)[0].to(steps)
else:
timestep_value = torch.tensor(
timestep, device=steps.device, dtype=steps.dtype)
matched_step_index = torch.isclose(
steps, timestep_value).nonzero()
if len(matched_step_index) > 0:
current_step_index = matched_step_index.item()
else:
for index in range(len(steps) - 1):
if ((steps[index] - timestep_value)
* (steps[index + 1] - timestep_value) <= 0):
current_step_index = index
break
else:
current_step_index = 0
current_percent = current_step_index / (len(steps) - 1)
active = (
self.start_percent <= current_percent
<= self.end_percent
)
if active:
uncond = self.conds.get("negative", None)
scale = self.cfg
else:
uncond = None
scale = 1.0
return sampling_function(
self.inner_model, x, timestep, uncond,
self.conds.get("positive", None), scale,
model_options=model_options, seed=seed)
rt = current_runtime()
model_value = await rt.refs.resolve(model)
positive_value = await rt.refs.resolve(positive)
negative_value = await rt.refs.resolve(negative)
guider = ScheduledCFGGuider(model_value)
guider.set_conds(positive_value, negative_value)
guider.set_cfg(cfg_value, start_value, end_value, sigma_bounds)
return GuiderRef._wrap(
await rt.refs.create("GUIDER", guider)) # type: ignore[return-value]
async def _sampling_spatial_crop_inputs(
self, owner: Ref, regions: list, source_width: int,
source_height: int, target_width: int, target_height: int,
) -> Ref:
"""Clone a model/guider and crop only self-declared spatial patches.
Tile planning deliberately stays in the calling node. Core supplies a
narrow ownership boundary: a model patch may implement
``spatial_crop_inputs`` to return an independent patch containing the
requested windows. Unknown patches are left untouched.
"""
import copy
import comfy.model_patcher
if owner.kind not in {"MODEL", "GUIDER"}:
raise TypeError(
"spatial_crop_inputs requires a MODEL or GUIDER ref")
if isinstance(source_width, bool) or not isinstance(source_width, int):
raise TypeError("source_width must be an integer")
if isinstance(source_height, bool) or not isinstance(source_height, int):
raise TypeError("source_height must be an integer")
if not 1 <= source_width <= 1_000_000:
raise ValueError("source_width must be in [1, 1000000]")
if not 1 <= source_height <= 1_000_000:
raise ValueError("source_height must be in [1, 1000000]")
if isinstance(target_width, bool) or not isinstance(target_width, int):
raise TypeError("target_width must be an integer")
if isinstance(target_height, bool) or not isinstance(target_height, int):
raise TypeError("target_height must be an integer")
if not 1 <= target_width <= 16384:
raise ValueError("target_width must be in [1, 16384]")
if not 1 <= target_height <= 16384:
raise ValueError("target_height must be in [1, 16384]")
if not isinstance(regions, list) or not 1 <= len(regions) <= 4096:
raise ValueError("regions must contain between 1 and 4096 tiles")
if len(regions) * target_width * target_height > 268_435_456:
raise ValueError("spatial crop tile batch exceeds the size limit")
checked_regions: list[tuple[int, int, int, int]] = []
for region in regions:
if not isinstance(region, (list, tuple)) or len(region) != 4:
raise TypeError("each spatial crop region must have four integers")
if any(isinstance(value, bool) or not isinstance(value, int)
for value in region):
raise TypeError("each spatial crop region must have four integers")
left, top, right, bottom = region
if not (0 <= left < right <= source_width):
raise ValueError("spatial crop x coordinates are outside the source")
if not (0 <= top < bottom <= source_height):
raise ValueError("spatial crop y coordinates are outside the source")
checked_regions.append((left, top, right, bottom))
def crop_model(value):
if not isinstance(value, comfy.model_patcher.ModelPatcher):
raise TypeError(
"spatial_crop_inputs requires a ComfyUI ModelPatcher")
cloned = value.clone()
patches = cloned.model_options.get(
"transformer_options", {}).get("patches", {})
replacements: dict[int, Any] = {}
for module_patches in patches.values():
for index, patch in enumerate(module_patches):
crop = getattr(patch, "spatial_crop_inputs", None)
if not callable(crop):
continue
identity = id(patch)
if identity not in replacements:
replacements[identity] = crop(
regions=checked_regions,
source_width=source_width,
source_height=source_height,
target_width=target_width,
target_height=target_height,
)
module_patches[index] = replacements[identity]
return cloned
runtime = current_runtime()
value = await runtime.refs.resolve(owner)
if owner.kind == "MODEL":
cropped = crop_model(value)
return ModelRef._wrap(await runtime.refs.create("MODEL", cropped))
# Guiders own one or more ModelPatchers. A shallow object copy retains
# the guider algorithm/configuration while each model attribute gets an
# independent cropped clone. This also covers dual-model guiders.
guider = copy.copy(value)
model_replacements: dict[int, Any] = {}
found_model = False
for name, candidate in vars(value).items():
if not isinstance(candidate, comfy.model_patcher.ModelPatcher):
continue
found_model = True
identity = id(candidate)
if identity not in model_replacements:
model_replacements[identity] = crop_model(candidate)
setattr(guider, name, model_replacements[identity])
if not found_model:
raise TypeError(
"spatial_crop_inputs requires a guider that owns a ModelPatcher")
primary = getattr(guider, "model_patcher", None)
if primary is not None and hasattr(value, "model_options"):
guider.model_options = primary.model_options
return GuiderRef._wrap(await runtime.refs.create("GUIDER", guider))
async def _model_ground_image(
self, model: "ModelRef", image: "ImageRef",
conditioning: "CondRef", threshold: float = 0.5,
refine_iterations: int = 2, individual_masks: bool = True,
max_detections: int = 64,
) -> tuple["MaskRef", list[list[dict[str, float]]]]:
"""Run the canonical core text-grounding implementation on SAM3.
The reusable boundary is deliberately smaller than SAM3_Detect's full
point/box prompting node. It exposes the common text-grounding intent
needed by layout, crop, and interrogation packs while leaving those
packs' layout and selection algorithms outside core.
"""
import math
import torch
if not math.isfinite(float(threshold)) or not 0.0 <= threshold <= 1.0:
raise ValueError("grounding threshold must be finite and in [0, 1]")
if (isinstance(refine_iterations, bool)
or not isinstance(refine_iterations, int)
or not 0 <= refine_iterations <= 5):
raise ValueError("grounding refine_iterations must be in [0, 5]")
if not isinstance(individual_masks, bool):
raise TypeError("grounding individual_masks must be a bool")
if (isinstance(max_detections, bool)
or not isinstance(max_detections, int)
or not 1 <= max_detections <= 256):
raise ValueError("grounding max_detections must be in [1, 256]")
rt = current_runtime()
model_value = await rt.refs.resolve(model)
pixels = await rt.refs.resolve(image)
cond_value = await rt.refs.resolve(conditioning)
base_model = getattr(model_value, "model", None)
diffusion_model = getattr(base_model, "diffusion_model", None)
config = getattr(getattr(base_model, "model_config", None),
"unet_config", None)
image_family = config.get("image_model") if isinstance(config, dict) else None
if (image_family not in {"SAM3", "SAM31"}
or type(diffusion_model).__module__
!= "comfy.ldm.sam3.detector"
or type(diffusion_model).__name__ != "SAM3Model"):
raise TypeError(
"model.ground_image requires an official SAM3/SAM3.1 MODEL")
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or pixels.shape[0] < 1 or pixels.shape[0] > 64
or pixels.shape[-1] < 3):
raise ValueError("image grounding requires a bounded BHWC RGB batch")
batch, height, width = map(int, pixels.shape[:3])
if (height < 1 or width < 1
or batch * height * width > 268_435_456):
raise ValueError("image grounding input dimensions are invalid")
mask_planes = max_detections if individual_masks else batch
if mask_planes * height * width > 268_435_456:
raise ValueError("image grounding mask result would be too large")
if (not isinstance(cond_value, (list, tuple)) or not cond_value
or not isinstance(cond_value[0], (list, tuple))):
raise TypeError("image grounding requires text CONDITIONING")
from comfy_extras.nodes_sam3 import SAM3_Detect
output = SAM3_Detect.execute(
model_value,
pixels,
conditioning=cond_value,
threshold=float(threshold),
refine_iterations=refine_iterations,
individual_masks=individual_masks,
)
values = getattr(output, "result", output)
if (not isinstance(values, (list, tuple)) or len(values) < 2
or not isinstance(values[0], torch.Tensor)
or values[0].ndim != 3
or not isinstance(values[1], list)
or len(values[1]) != batch):
raise RuntimeError("SAM3 grounding returned an invalid result")
masks = values[0]
projected: list[list[dict[str, float]]] = []
remaining = max_detections
for raw_frame in values[1]:
if not isinstance(raw_frame, list):
raise RuntimeError("SAM3 grounding returned invalid frame boxes")
frame: list[dict[str, float]] = []
for raw_box in raw_frame[:remaining]:
if not isinstance(raw_box, dict):
raise RuntimeError("SAM3 grounding returned an invalid box")
box: dict[str, float] = {}
for field_name in ("x", "y", "width", "height", "score"):
value = raw_box.get(field_name)
if (type(value) not in (int, float)
or not math.isfinite(float(value))):
raise RuntimeError(
f"SAM3 grounding box has invalid {field_name}")
box[field_name] = float(value)
if (box["width"] < 0.0 or box["height"] < 0.0
or not 0.0 <= box["score"] <= 1.0
or abs(box["x"]) > width * 4
or abs(box["y"]) > height * 4
or box["width"] > width * 4
or box["height"] > height * 4):
raise RuntimeError("SAM3 grounding box is outside its bounds")
frame.append(box)
remaining -= len(frame)
projected.append(frame)
detection_count = sum(map(len, projected))
if individual_masks:
if masks.shape[0] < detection_count:
raise RuntimeError("SAM3 grounding returned fewer masks than boxes")
masks = masks[:detection_count]
elif masks.shape[0] != batch:
raise RuntimeError("SAM3 union masks do not match the image batch")
if tuple(map(int, masks.shape[-2:])) != (height, width):
raise RuntimeError("SAM3 grounding masks have the wrong dimensions")
mask_ref = MaskRef._wrap(await rt.refs.create("MASK", masks))
return mask_ref, projected # type: ignore[return-value]
async def _model_patch(self, model: "ModelRef", transform: str,
params: dict) -> "ModelRef":
"""Apply a named transform on the trusted plane.
Validation happens HERE, not in the guest. A guest-side check is a
convenience for the node author and nothing more — the value that
arrives is whatever the guest chose to send.
"""
from . import _model_transforms
rt = current_runtime()
checked = _model_transforms.validate(transform, params or {})
# Ref-valued parameters resolve through the ref table, which enforces
# the kind. The implementation receives a real object and never a token,
# so it cannot be tricked into treating one kind as another.
spec = _model_transforms.TRANSFORMS[transform].params
for name, value in list(checked.items()):
if isinstance(spec[name], _model_transforms.RefOf) and value is not None:
checked[name] = await rt.refs.resolve(value)
m = await rt.refs.resolve(model)
return ModelRef._wrap(await rt.refs.create( # type: ignore[return-value]
"MODEL", _model_transforms.TRANSFORMS[transform].apply(m, **checked)))
async def _model_transforms(self, model: "ModelRef") -> list[dict]:
from . import _model_transforms
await current_runtime().refs.resolve(model)
return _model_transforms.describe_all()
async def _model_is_flow(self, model: "ModelRef") -> bool:
import comfy.model_base
value = await current_runtime().refs.resolve(model)
return value.model.model_type == comfy.model_base.ModelType.FLOW
async def _model_family(self, model: "ModelRef") -> str:
import comfy.supported_models
value = await current_runtime().refs.resolve(model)
config = getattr(getattr(value, "model", None), "model_config", None)
families = (
("sdxl_refiner", (comfy.supported_models.SDXLRefiner,)),
("sdxl", (comfy.supported_models.SDXL,)),
("sd1", (
comfy.supported_models.SD15,
comfy.supported_models.SD20,
)),
("svd", (comfy.supported_models.SVD_img2vid,)),
("sd3", (comfy.supported_models.SD3,)),
("hunyuan_dit", (comfy.supported_models.HunyuanDiT,)),
("flux", (comfy.supported_models.Flux,)),
("mochi", (comfy.supported_models.GenmoMochi,)),
)
for family, classes in families:
if isinstance(config, classes):
return family
return "unknown"
async def _model_unet_context_dim(
self, model: "ModelRef",
) -> Optional[int]:
value = await current_runtime().refs.resolve(model)
model_config = getattr(getattr(value, "model", None), "model_config", None)
unet_config = getattr(model_config, "unet_config", None)
context_dim = (
unet_config.get("context_dim")
if isinstance(unet_config, dict) else None
)
if (
isinstance(context_dim, bool)
or not isinstance(context_dim, (int, float))
or not 1 <= float(context_dim) <= 1_000_000
):
return None
return int(context_dim)
async def _model_is_zero_terminal_snr(
self, model: "ModelRef",
) -> bool:
value = await current_runtime().refs.resolve(model)
try:
model_sampling = value.get_model_object("model_sampling")
except (AttributeError, KeyError) as error:
raise ValueError("MODEL has no sampling schedule") from error
return bool(getattr(model_sampling, "zsnr", False))
async def _model_sigma_for_percent(
self, model: "ModelRef", percent: float,
actual_endpoints: bool = False,
) -> float:
import math
if isinstance(percent, bool) or not isinstance(percent, (int, float)):
raise TypeError("sampling percent must be numeric")
percent = float(percent)
if not math.isfinite(percent) or not 0.0 <= percent <= 1.0:
raise ValueError("sampling percent must be finite and in [0, 1]")
if type(actual_endpoints) is not bool:
raise TypeError("actual_endpoints must be a bool")
value = await current_runtime().refs.resolve(model)
try:
model_sampling = value.get_model_object("model_sampling")
except (AttributeError, KeyError) as error:
raise ValueError("MODEL has no sampling schedule") from error
result = model_sampling.percent_to_sigma(percent)
if actual_endpoints and percent == 0.0:
result = model_sampling.sigma_max
elif actual_endpoints and percent == 1.0:
result = model_sampling.sigma_min
if hasattr(result, "item"):
result = result.item()
result = float(result)
if not math.isfinite(result) or abs(result) > 1_000_000_000_000.0:
raise ValueError("MODEL returned an invalid sigma")
return result
async def _model_sampling_sigma_delta(
self, model: "ModelRef", steps: int, sampler_name: str,
scheduler: str, start_step: int, end_step: int,
denoise: float = 1.0,
sigma_schedule: Optional[dict] = None,
) -> float:
import math
import comfy.model_management
import comfy.samplers
steps = int(steps)
start_step = int(start_step)
end_step = int(end_step)
denoise = float(denoise)
if not 1 <= steps <= 10000:
raise ValueError("steps must be in [1, 10000]")
if not 0 <= start_step <= end_step <= steps:
raise ValueError("sigma step range is outside the schedule")
if not math.isfinite(denoise) or not 0.0 <= denoise <= 1.0:
raise ValueError("denoise must be finite and in [0, 1]")
if sampler_name not in comfy.samplers.KSampler.SAMPLERS:
raise ValueError("unknown sampler name")
if scheduler not in comfy.samplers.KSampler.SCHEDULERS:
raise ValueError("unknown scheduler name")
value = await current_runtime().refs.resolve(model)
comfy.model_management.load_model_gpu(value)
if sigma_schedule is None:
sampler = comfy.samplers.KSampler(
value,
steps=steps,
device=comfy.model_management.get_torch_device(),
sampler=sampler_name,
scheduler=scheduler,
denoise=denoise,
model_options=value.model_options,
)
sigmas = sampler.sigmas
else:
if not isinstance(sigma_schedule, dict):
raise TypeError("sigma_schedule must be a mapping or None")
total_steps = steps if denoise > 0.9999 else int(steps / denoise)
kind = sigma_schedule.get("kind")
if kind == "gits":
if set(sigma_schedule) != {"kind", "coeff", "denoise"}:
raise ValueError("GITS sigma schedule has unknown fields")
coeff = float(sigma_schedule["coeff"])
schedule_denoise = float(sigma_schedule["denoise"])
if not 0.8 <= coeff <= 1.5:
raise ValueError("GITS coefficient must be in [0.8, 1.5]")
if not 0.0 <= schedule_denoise <= 1.0:
raise ValueError("GITS denoise must be in [0, 1]")
from comfy_extras.nodes_gits import GITSScheduler
sigmas = GITSScheduler.execute(
coeff, total_steps, schedule_denoise,
)[0]
elif kind == "ays":
if set(sigma_schedule) != {"kind", "model_type", "denoise"}:
raise ValueError("AYS sigma schedule has unknown fields")
model_type = str(sigma_schedule["model_type"])
schedule_denoise = float(sigma_schedule["denoise"])
if model_type not in {"SD1", "SDXL", "SVD"}:
raise ValueError("AYS model type must be SD1, SDXL, or SVD")
if not 0.0 <= schedule_denoise <= 1.0:
raise ValueError("AYS denoise must be in [0, 1]")
from comfy_extras.nodes_align_your_steps import (
AlignYourStepsScheduler,
)
sigmas = AlignYourStepsScheduler().get_sigmas(
model_type, total_steps, schedule_denoise,
)[0]
else:
raise ValueError("sigma_schedule kind is not supported")
if denoise <= 0.9999:
sigmas = sigmas[-(steps + 1):]
scale = float(value.model.latent_format.scale_factor)
return float((sigmas[start_step] - sigmas[end_step]).detach().cpu()) / scale
class InProcessExecutionBackend:
async def dispatch(
self,
plan: ExecutionPlan,
local_call: Callable[[], Awaitable[Any]],
runtime: Optional[Runtime] = None,
) -> Any:
return await local_call()
# --------------------------------------------------------------------------- #
# Input/output ref marshaling for SDK nodes (those declaring ``SDK_REFS``).
# Heavy inputs become refs before execute(); output refs resolve back to real
# objects for downstream (legacy) nodes. This is what makes execute() see
# assets, not buffers. Under the overlay this happens at the process boundary.
# --------------------------------------------------------------------------- #
def _looks_like_tensor(v: Any) -> bool:
return type(v).__name__ == "Tensor" and hasattr(v, "shape")
def _is_plain_data(v: Any) -> bool:
"""Whether a value can cross a process boundary as data.
Deliberately a whitelist. Everything else is a live engine object — a
ModelPatcher, a conditioning list holding tensors, a VAE — and handing one
to an out-of-process node is either impossible (it will not serialize) or
exactly what the boundary exists to prevent.
"""
if v is None or isinstance(v, (str, bool, int, float, bytes)):
return True
if isinstance(v, (list, tuple)):
return all(_is_plain_data(x) for x in v)
if isinstance(v, dict):
return all(isinstance(k, str) and _is_plain_data(x) for k, x in v.items())
return False
def _is_ipadapter_pipe(v: Any) -> bool:
"""Recognize the fixed host pipeline shape without inspecting its models."""
return (
isinstance(v, dict)
and v.get("secure_kind") == "ipadapter.pipeline"
and set(v) == {"secure_kind", "ipadapter", "clip_vision"}
and isinstance(v.get("ipadapter"), dict)
and v.get("clip_vision") is not None
) or (
isinstance(v, (list, tuple))
and len(v) == 5
and callable(v[4])
)
def _is_image_preprocessor(v: Any) -> bool:
"""Recognize Inspire's host-only SEGS provider protocol narrowly."""
value_type = type(v)
return (
value_type.__module__.endswith("inspire.segs_support")
and value_type.__name__.endswith("_wrapper")
and callable(getattr(v, "apply", None))
)
def _is_interpolation_states(v: Any) -> bool:
"""Recognize the fixed Frame-Interpolation policy without behavior."""
value_type = type(v)
if value_type.__name__ != "InterpolationStateList":
return False
try:
fields = object.__getattribute__(v, "__dict__")
except (AttributeError, TypeError):
return False
return (
type(fields) is dict
and set(fields) == {"frame_indices", "is_skip_list"}
)
#: Live engine objects a node may receive, by the ref type that stands in for
#: them. Detection is duck-typed because these classes live in `comfy.*`, which
#: this module must not import.
def _ref_type_for(v: Any) -> tuple[type, str]:
"""Choose the narrowest handle that preserves the value's authority."""
if _looks_like_tensor(v) and getattr(v, "ndim", None) == 1:
return SigmasRef, "SIGMAS"
if _looks_like_tensor(v):
return ImageRef, "IMAGE"
if _is_ipadapter_pipe(v):
return IpAdapterRef, "IPADAPTER_PIPE"
if _is_image_preprocessor(v):
return ImagePreprocessorRef, "IMAGE_PREPROCESSOR"
if _is_interpolation_states(v):
return InterpolationStatesRef, "INTERPOLATION_STATES"
value_type = type(v)
if value_type.__module__.endswith("adv_control.utils"):
if value_type.__name__ == "ControlWeights":
return ControlNetWeightsRef, "CONTROL_NET_WEIGHTS"
if value_type.__name__ == "TimestepKeyframeGroup":
return TimestepKeyframeRef, "TIMESTEP_KEYFRAME"
if (hasattr(v, "encode_image") and hasattr(v, "patcher") and
hasattr(v, "image_size")):
return ClipVisionRef, "CLIP_VISION"
if (type(v).__name__ == "Output" and hasattr(v, "image_embeds")):
return ClipVisionOutputRef, "CLIP_VISION_OUTPUT"
if (hasattr(v, "get_control") and hasattr(v, "set_extra_arg") and
hasattr(v, "copy")):
return ControlNetRef, "CONTROL_NET"
if hasattr(v, "get_cond") and hasattr(v, "model"):
return StyleModelRef, "STYLE_MODEL"
if (type(v).__name__ == "ImageModelDescriptor"
and hasattr(v, "model") and hasattr(v, "scale")
and hasattr(v, "to")):
return UpscaleModelRef, "UPSCALE_MODEL"
if (hasattr(v, "get_stream_source") and hasattr(v, "get_components")
and hasattr(v, "save_to")):
return VideoRef, "VIDEO"
if (hasattr(v, "model_patcher") and hasattr(v, "set_conds")
and hasattr(v, "outer_sample") and hasattr(v, "predict_noise")):
return GuiderRef, "GUIDER"
if (type(v).__name__ == "KSAMPLER"
and callable(getattr(v, "sample", None))
and callable(getattr(v, "sampler_function", None))
and isinstance(getattr(v, "extra_options", None), dict)):
return SamplerRef, "SAMPLER"
inner = getattr(v, "model", None)
if (hasattr(v, "model_options") and hasattr(v, "load_device")
and hasattr(inner, "set_position")
and hasattr(inner, "set_empty")
and hasattr(inner, "position_net")
and hasattr(inner, "module_list")):
return GligenRef, "GLIGEN"
if hasattr(v, "model_options") and hasattr(v, "load_device"):
return ModelRef, "MODEL" # ModelPatcher
if hasattr(v, "encode_from_tokens") or hasattr(v, "tokenize"):
return ClipRef, "CLIP"
if hasattr(v, "decode") and hasattr(v, "encode"):
return VaeRef, "VAE"
if isinstance(v, dict):
if v.get("secure_kind") == "image_inpaint.big-lama" and set(v) >= {
"model", "architecture", "lock",
}:
return InpaintModelRef, "INPAINT_MODEL"
if v.get("secure_kind") in {"sam.v1", "sam.v2"} and set(v) >= {
"model", "architecture", "device_mode", "lock",
}:
return SamModelRef, "SAM_MODEL"
if v.get("secure_kind") == "onnx.object_detector" and set(v) >= {
"model", "lock",
}:
return OnnxDetectorRef, "ONNX_DETECTOR"
if v.get("secure_kind") == "object_detector.rt_detr" and "model" in v:
return ObjectDetectorRef, "OBJECT_DETECTOR"
if v.get("secure_kind") == "classifier_scores.v1" and "scores" in v:
return ClassifierScoresRef, "CLASSIFIER_SCORES"
if v.get("secure_kind") == "image_classifier.onnx" and set(v) >= {
"session", "input_name", "output_name", "class_count", "lock",
}:
return ImageClassifierRef, "IMAGE_CLASSIFIER"
if v.get("secure_kind") == "powerpaint.pipeline" and set(v) >= {
"powerpaint", "clip",
}:
return PowerPaintRef, "POWERPAINT_MODEL"
if set(v) >= {"model", "processor", "architecture", "labels"}:
return ImageClassifierRef, "IMAGE_CLASSIFIER"
if set(v) >= {"model", "processor"}:
return ClipSegRef, "CLIPSEGMODEL"
if "samples" in v:
return LatentRef, "LATENT"
if "waveform" in v and "sample_rate" in v:
return AudioRef, "AUDIO"
return ValueRef, "VALUE"
if isinstance(v, (list, tuple)):
if (v and isinstance(v[0], (list, tuple)) and len(v[0]) == 2 and
_looks_like_tensor(v[0][0]) and isinstance(v[0][1], dict)):
return CondRef, "CONDITIONING"
return ValueRef, "VALUE"
return OpaqueRef, "OPAQUE"
async def wrap_inputs(resolver: "RefResolver", inputs: dict) -> dict:
"""Replace live engine objects with refs before a node sees them.
An SDK_REFS node is handed handles, never the objects themselves, so the
same node body works in-process and out-of-process. The rule is by
capability rather than by an enumerated type list: if a value cannot cross
as data, it becomes a handle. That is what lets a node take a MODEL or a
CONDITIONING — which are live engine objects — and still run in a guest.
"""
async def wrap(value: Any) -> Any:
if _is_plain_data(value):
return value
# Custom structured sockets (for example rgthree's CONTEXT) may carry
# several live engine objects. Preserve mappings/sequences and replace
# each leaf with its narrow handle; wrapping the whole structure as
# VALUE would try to export MODEL/CLIP objects as data.
if isinstance(value, dict) and not (
"samples" in value
or ("waveform" in value and "sample_rate" in value)
or set(value) >= {"model", "processor"}
or _is_ipadapter_pipe(value)
):
return {key: await wrap(item) for key, item in value.items()}
if isinstance(value, (list, tuple)) and not (
_is_ipadapter_pipe(value)
or (
value
and isinstance(value[0], (list, tuple))
and len(value[0]) == 2
and _looks_like_tensor(value[0][0])
and isinstance(value[0][1], dict)
)
):
wrapped = [await wrap(item) for item in value]
return tuple(wrapped) if isinstance(value, tuple) else wrapped
ref_cls, kind = _ref_type_for(value)
return ref_cls._wrap(await resolver.create(kind, value))
return {key: await wrap(value) for key, value in inputs.items()}
async def unwrap_outputs(resolver: "RefResolver", node_output: Any) -> Any:
args = getattr(node_output, "result", None)
if not args:
return node_output
async def resolve(value: Any) -> Any:
if isinstance(value, Ref):
return await resolver.resolve(value)
if isinstance(value, list):
return [await resolve(item) for item in value]
if isinstance(value, tuple):
return tuple([await resolve(item) for item in value])
if isinstance(value, dict):
return {key: await resolve(item) for key, item in value.items()}
return value
resolved = [await resolve(value) for value in args]
from ._io import NodeOutput
# Rebuilding the NodeOutput must preserve everything that is not a result.
# Dropping `ui` here silently made every SDK_REFS node unable to be an
# output node: ComfyUI only sends the `executed` event that carries results
# to the frontend for nodes returning ui data, so a PreviewImage-style node
# would run correctly and then display nothing. `expand` and
# `block_execution` matter for the same reason — they are node output, not
# node results, and resolving refs has no business discarding them.
return NodeOutput(
*resolved,
ui=getattr(node_output, "ui", None),
expand=getattr(node_output, "expand", None),
block_execution=getattr(node_output, "block_execution", None),
)
# --------------------------------------------------------------------------- #
# Provider registry — the seam the overlay attaches to.
# --------------------------------------------------------------------------- #
class Providers:
def __init__(self) -> None:
self.execution_backend: ExecutionBackend = InProcessExecutionBackend()
self.ctx_provider: CtxProvider = InProcessCtxProvider()
self.ops_provider: OpsProvider = InProcessOps()
self.ref_resolver_factory: Callable[[], RefResolver] = InProcessRefResolver
self._overlay_name: Optional[str] = None
self._extension_host_module_url: Optional[str] = None
# Overlay entry points -------------------------------------------------- #
def register_execution_backend(self, impl: ExecutionBackend) -> None:
logger.info("SDK: execution backend -> %s", type(impl).__name__)
self.execution_backend = impl
def register_ctx_provider(self, impl: CtxProvider) -> None:
logger.info("SDK: ctx provider -> %s", type(impl).__name__)
self.ctx_provider = impl
def register_ops_provider(self, impl: OpsProvider) -> None:
logger.info("SDK: ops provider -> %s", type(impl).__name__)
self.ops_provider = impl
def register_ref_resolver_factory(self, factory: Callable[[], RefResolver]) -> None:
logger.info("SDK: ref resolver -> %s", getattr(factory, "__name__", factory))
self.ref_resolver_factory = factory
def register_extension_host(self, module_url: str) -> None:
if not module_url:
raise ValueError("extension host module URL must not be empty")
self._extension_host_module_url = module_url
@property
def frontend_runtime_config(self) -> dict[str, Any]:
if self._extension_host_module_url is None:
return {}
return {
"extension_host": {
"module_url": self._extension_host_module_url,
}
}
@property
def overlay_active(self) -> bool:
return self._overlay_name is not None
providers = Providers()
# --------------------------------------------------------------------------- #
# Overlay loader — the "sidecar" attach point. OSS core calls load_overlay()
# once at startup; if COMFY_OVERLAY_MODULE is unset it is a no-op and behavior
# is pure in-process. The overlay module (proprietary, separate repo) exposes
# ``register(providers)`` and installs its implementations. Mirrors how
# load_custom_node imports by file path.
# --------------------------------------------------------------------------- #
def load_overlay(spec: Optional[str] = None) -> bool:
spec = spec if spec is not None else os.environ.get(OVERLAY_ENV)
if not spec:
return False
import importlib
import importlib.util
import sys
module = None
if os.path.exists(spec):
norm = os.path.normpath(spec)
if os.path.isdir(norm):
# Package directory: put its parent on sys.path and import by name
# so intra-package relative imports resolve normally.
pkg_name = os.path.basename(norm)
parent = os.path.dirname(norm)
if parent not in sys.path:
sys.path.insert(0, parent)
module = importlib.import_module(pkg_name)
else:
# Single .py file.
modspec = importlib.util.spec_from_file_location("comfy_overlay", norm)
if modspec and modspec.loader:
module = importlib.util.module_from_spec(modspec)
sys.modules["comfy_overlay"] = module
modspec.loader.exec_module(module)
else:
module = importlib.import_module(spec) # importable module name
if module is None:
raise RuntimeError(f"SDK overlay {spec!r} could not be loaded")
register = getattr(module, "register", None)
if not callable(register):
raise RuntimeError(
f"SDK overlay {spec!r} has no register(providers) entrypoint"
)
register(providers)
providers._overlay_name = getattr(module, "__name__", spec)
logger.info("SDK overlay loaded: %s", providers._overlay_name)
return True
def should_load_legacy_custom_nodes(
*, secure_mode: bool, disabled: bool, has_whitelist: bool
) -> bool:
return not secure_mode and (not disabled or has_whitelist)