Files
nyueki-ComfyUI-RemoteCLIPLo…/__init__.py
T
nyueki c71fc4803d Update protocol version and improve connection handling
Updated protocol version and added error handling for packet size limits. Enhanced connection handling with new timeout and retry parameters.
2026-10-25 00:50:01 -04:00

550 lines
23 KiB
Python

import os
import math
import json
import time
import hmac
import socket
import struct
import threading
from collections import OrderedDict
import torch
import folder_paths
import comfy.sd
import comfy.utils
DEFAULT_PORT = 8181
PROTOCOL_VERSION = 3
HEADER_LEN_FMT = ">Q"
HEADER_LEN_SIZE = struct.calcsize(HEADER_LEN_FMT)
SOCKET_TIMEOUT = 120
GENERATE_TIMEOUT = 3600
CONNECT_RETRIES = 3
CONNECT_BACKOFF = 1.0
RECV_CHUNK = 1 << 20
MAX_HEADER_BYTES = 8 * 1024 * 1024
MAX_BLOB_BYTES = 512 * 1024 * 1024
PATCHED_CLIP_CACHE = 4
ALLOWED_DTYPES = {
"torch.float32": torch.float32,
"torch.float16": torch.float16,
"torch.bfloat16": torch.bfloat16,
"torch.int64": torch.int64,
"torch.int32": torch.int32,
"torch.uint8": torch.uint8,
"torch.bool": torch.bool,
}
def log(msg):
print(f"[RemoteCLIP {time.strftime('%H:%M:%S')}] {msg}", flush=True)
_LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1", "0.0.0.0"}
def resolve_transport_dtype(mode, ip):
if mode == "fp16":
return "torch.float16"
if mode == "fp32":
return "torch.float32"
is_local = ip in _LOCAL_HOSTS
return None if is_local else "torch.float16"
def _recv_exact(sock, size):
chunks = []
remaining = size
while remaining > 0:
chunk = sock.recv(min(remaining, RECV_CHUNK))
if not chunk:
raise ConnectionError("Socket closed while reading")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def send_packet(sock, header, blob=b""):
header_bytes = json.dumps(header).encode("utf-8")
if len(header_bytes) > MAX_HEADER_BYTES or len(blob) > MAX_BLOB_BYTES:
raise ValueError("Packet exceeds protocol size limits")
prefix = struct.pack(HEADER_LEN_FMT, len(header_bytes))
sock.sendall(prefix + header_bytes)
if blob:
sock.sendall(blob)
def recv_header(sock):
header_len = struct.unpack(HEADER_LEN_FMT, _recv_exact(sock, HEADER_LEN_SIZE))[0]
if header_len > MAX_HEADER_BYTES:
raise ValueError(f"Header too large: {header_len} bytes")
header = json.loads(_recv_exact(sock, header_len).decode("utf-8"))
if not isinstance(header, dict):
raise ValueError("Packet header must be an object")
return header
def recv_blob(sock, header):
blob_size = header.get("blob_size", 0)
if type(blob_size) is not int or not 0 <= blob_size <= MAX_BLOB_BYTES:
raise ValueError(f"Blob too large: {blob_size} bytes")
return _recv_exact(sock, blob_size) if blob_size else b""
def recv_packet(sock):
header = recv_header(sock)
blob = recv_blob(sock, header)
return header, blob
def _set_socket_opts(sock):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.settimeout(SOCKET_TIMEOUT)
def pack_tensors(tensors, transport_dtype=None):
meta = {}
blobs = []
offset = 0
for name, t in tensors.items():
if t is None:
raise ValueError(f"Tensor '{name}' is None")
t = t.detach().cpu()
orig_dtype = str(t.dtype)
if t.is_floating_point():
if transport_dtype is not None:
t = t.to(transport_dtype)
elif t.dtype not in (torch.float16, torch.float32):
t = t.to(torch.float32)
t = t.contiguous()
raw = t.numpy().tobytes()
meta[name] = {
"dtype": str(t.dtype),
"orig_dtype": orig_dtype,
"shape": list(t.shape),
"offset": offset,
"size": len(raw),
}
offset += len(raw)
blobs.append(raw)
return meta, b"".join(blobs)
def unpack_tensors(meta, blob):
out = {}
view = memoryview(blob)
for name, info in meta.items():
dtype_name = info["dtype"]
if dtype_name not in ALLOWED_DTYPES:
raise ValueError(f"Refusing to decode disallowed dtype: {dtype_name}")
dtype = ALLOWED_DTYPES[dtype_name]
start, size, shape = info["offset"], info["size"], info["shape"]
if (type(start) is not int or type(size) is not int or
start < 0 or size < 0 or start + size > len(blob)):
raise ValueError("Invalid tensor byte range")
if not isinstance(shape, list) or any(type(d) is not int or d < 0 for d in shape):
raise ValueError("Invalid tensor shape")
count = math.prod(shape)
if count * torch.empty((), dtype=dtype).element_size() != size:
raise ValueError("Tensor shape does not match byte size")
if count == 0:
t = torch.empty(shape, dtype=dtype)
else:
raw = bytearray(view[start:start + size])
t = torch.frombuffer(raw, dtype=dtype).reshape(shape).clone()
orig = info.get("orig_dtype")
if orig in ALLOWED_DTYPES and ALLOWED_DTYPES[orig] != dtype:
t = t.to(ALLOWED_DTYPES[orig])
out[name] = t
return out
def extract_tensors(obj, prefix, out):
# Tag every container so user dictionaries cannot mimic tensor references.
if isinstance(obj, torch.Tensor):
key = f"tensor_{len(out)}"
out[key] = obj
return {"kind": "tensor", "value": key}
if isinstance(obj, dict):
return {"kind": "dict", "value": [
[extract_tensors(k, prefix, out), extract_tensors(v, prefix, out)]
for k, v in obj.items()]}
if isinstance(obj, (list, tuple)):
return {"kind": "tuple" if isinstance(obj, tuple) else "list",
"value": [extract_tensors(v, prefix, out) for v in obj]}
return {"kind": "value", "value": obj}
def restore_tensors(obj, tensors):
kind, value = obj["kind"], obj["value"]
if kind == "tensor":
return tensors[value]
if kind == "dict":
return {restore_tensors(k, tensors): restore_tensors(v, tensors) for k, v in value}
if kind in ("list", "tuple"):
values = [restore_tensors(v, tensors) for v in value]
return tuple(values) if kind == "tuple" else values
if kind != "value":
raise ValueError(f"Invalid structure kind: {kind}")
return value
def pack_object(obj, transport_dtype=None):
tensors = {}
structure = extract_tensors(obj, "", tensors)
meta, blob = pack_tensors(tensors, transport_dtype)
return {"struct": structure, "tensors": meta, "blob_size": len(blob)}, blob
def unpack_object(header, blob):
return restore_tensors(header["struct"], unpack_tensors(header["tensors"], blob))
class _Connection:
def __init__(self, ip, port, auth_token="", connect_timeout=SOCKET_TIMEOUT,
request_attempts=CONNECT_RETRIES, connect_backoff=CONNECT_BACKOFF,
encode_timeout=SOCKET_TIMEOUT):
self.ip, self.port, self.auth_token = ip, port, auth_token
self.connect_timeout = float(connect_timeout)
self.request_attempts = int(request_attempts)
self.connect_backoff = float(connect_backoff)
self.encode_timeout = float(encode_timeout)
for value in (self.connect_timeout, self.encode_timeout):
if not math.isfinite(value) or value <= 0:
raise ValueError("Timeouts must be finite and positive")
if self.request_attempts < 1:
raise ValueError("Request attempts must be at least 1")
if not math.isfinite(self.connect_backoff) or self.connect_backoff < 0:
raise ValueError("Retry delay must be finite and nonnegative")
self._sock = None
self._lock = threading.Lock()
def _connect(self):
sock = socket.create_connection((self.ip, self.port), timeout=self.connect_timeout)
try:
_set_socket_opts(sock)
except Exception:
sock.close()
raise
self._sock = sock
log(f"Connected to worker {self.ip}:{self.port}")
def _close(self):
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
def request(self, header, blob=b"", response_timeout=None, replay_safe=True):
header = {**header, "auth": self.auth_token}
timeout = self.encode_timeout if response_timeout is None else response_timeout
if not math.isfinite(timeout) or timeout <= 0:
raise ValueError("Response timeout must be finite and positive")
with self._lock:
backoff = self.connect_backoff
for attempt in range(1, self.request_attempts + 1):
sending = False
try:
if self._sock is None:
self._connect()
self._sock.settimeout(timeout)
sending = True
send_packet(self._sock, header, blob)
result = recv_packet(self._sock)
self._sock.settimeout(self.encode_timeout)
return result
except (OSError, ConnectionError, struct.error, ValueError):
self._close()
# Generation may have executed even when its response was lost.
if attempt == self.request_attempts or (sending and not replay_safe):
raise
log(f"Request attempt {attempt}/{self.request_attempts} failed; retrying")
time.sleep(backoff)
backoff *= 2
class RemoteCLIPProxy:
def __init__(self, ip, port, auth_token="", transport_mode="auto",
connection=None, lora_stack=None, connect_timeout=SOCKET_TIMEOUT,
request_attempts=CONNECT_RETRIES, connect_backoff=CONNECT_BACKOFF,
encode_timeout=SOCKET_TIMEOUT, generate_timeout=GENERATE_TIMEOUT,
layer_idx=None):
self.ip, self.port = ip, port
self.transport_mode = transport_mode
self.transport_dtype_name = resolve_transport_dtype(transport_mode, ip)
self.transport_dtype = ALLOWED_DTYPES.get(self.transport_dtype_name)
self.generate_timeout = float(generate_timeout)
if not math.isfinite(self.generate_timeout) or self.generate_timeout <= 0:
raise ValueError("Generation timeout must be finite and positive")
self._conn = connection or _Connection(
ip, port, auth_token, connect_timeout, request_attempts,
connect_backoff, encode_timeout)
self.lora_stack = list(lora_stack or [])
self.layer_idx = layer_idx
def clone(self, disable_dynamic=False):
return RemoteCLIPProxy(
self.ip, self.port, transport_mode=self.transport_mode,
connection=self._conn, lora_stack=self.lora_stack,
generate_timeout=self.generate_timeout, layer_idx=self.layer_idx)
def clip_layer(self, layer_idx):
self.layer_idx = layer_idx
def with_lora(self, lora_name, strength_clip):
c = self.clone()
c.lora_stack = self.lora_stack + [[lora_name, float(strength_clip)]]
return c
def add_patches(self, *_args, **_kwargs):
raise NotImplementedError(
"Apply LoRAs to a remote CLIP with the 'LoraLoaderCLIPOnly' node.")
def _rpc(self, cmd, payload, generation=False):
# Token tensors retain their original numerical values. Transport precision
# applies to image/tokenizer inputs and embedding responses, not token IDs.
header, blob = pack_object(payload)
header.update(cmd=cmd, proto=PROTOCOL_VERSION,
lora_stack=self.lora_stack, layer_idx=self.layer_idx,
transport_dtype=self.transport_dtype_name)
response, data = self._conn.request(
header, blob,
response_timeout=self.generate_timeout if generation else None,
replay_safe=not generation)
if response.get("error"):
raise RuntimeError(f"Remote CLIP worker error: {response['error']}")
return unpack_object(response, data)
def tokenize(self, text, return_word_ids=False, **kwargs):
# Return actual token dictionaries: callers such as SDXL edit l/g streams.
return self._rpc("tokenize", {"text": text, "kwargs": {
**kwargs, "return_word_ids": return_word_ids}})
def encode_from_tokens(self, tokens, return_pooled=False, return_dict=False):
out = self._rpc("encode", {"tokens": tokens, "return_pooled": return_pooled})
if return_dict:
return out
if return_pooled:
return out["cond"], out.get("pooled_output")
return out["cond"]
def encode_from_tokens_scheduled(self, tokens, unprojected=False, add_dict=None, show_pbar=True):
return self._rpc("encode_scheduled", {
"tokens": tokens, "unprojected": unprojected,
"add_dict": {} if add_dict is None else add_dict, "show_pbar": show_pbar})
def generate(self, tokens, **gen_kwargs):
return self._rpc("generate", {"tokens": tokens, "gen_kwargs": gen_kwargs}, generation=True)
def decode(self, generated, skip_special_tokens=True):
return generated
class _Worker:
def __init__(self, clip, auth_token=""):
self.base_clip = clip
self.auth_token = auth_token
self._infer_lock = threading.Lock()
self._patched_clips = OrderedDict()
def _get_clip(self, lora_stack, layer_idx):
sig = json.dumps([lora_stack, layer_idx], sort_keys=True)
if sig in self._patched_clips:
self._patched_clips.move_to_end(sig)
return self._patched_clips[sig]
clip = self.base_clip.clone()
if layer_idx is not None:
clip.clip_layer(layer_idx)
for lora_name, strength in lora_stack:
if strength == 0:
continue
path = folder_paths.get_full_path("loras", lora_name)
if path is None:
raise FileNotFoundError(f"LoRA not found on worker: {lora_name}")
lora = comfy.utils.load_torch_file(path, safe_load=True)
_, clip = comfy.sd.load_lora_for_models(None, clip, lora, 0, strength)
self._patched_clips[sig] = clip
while len(self._patched_clips) > PATCHED_CLIP_CACHE:
self._patched_clips.popitem(last=False)
return clip
def execute(self, cmd, payload, lora_stack, layer_idx):
with self._infer_lock, torch.inference_mode():
clip = self._get_clip(lora_stack, layer_idx)
if cmd == "tokenize":
return clip.tokenize(payload["text"], **payload["kwargs"])
if cmd == "generate":
return clip.decode(clip.generate(payload["tokens"], **payload["gen_kwargs"]))
if cmd == "encode":
out = clip.encode_from_tokens(payload["tokens"],
return_pooled=payload["return_pooled"], return_dict=True)
elif cmd == "encode_scheduled":
out = clip.encode_from_tokens_scheduled(**payload)
else:
raise ValueError(f"Unknown command: {cmd}")
return out
class _Server:
def __init__(self, clip, host, port, token):
self.worker = _Worker(clip, token)
self.token = token
self.stopped = threading.Event()
self._clients = set()
self._clients_lock = threading.Lock()
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket.bind((host, port))
self.socket.listen(16)
self.socket.settimeout(0.5)
except Exception:
self.socket.close()
raise
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
self.thread.start()
@staticmethod
def _close_socket(sock):
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
sock.close()
def close(self):
self.stopped.set()
self._close_socket(self.socket)
with self._clients_lock:
clients = list(self._clients)
for conn in clients:
self._close_socket(conn)
self.thread.join(timeout=1)
def _accept_loop(self):
while not self.stopped.is_set():
try:
conn, addr = self.socket.accept()
except socket.timeout:
continue
except OSError:
break
with self._clients_lock:
if self.stopped.is_set():
conn.close()
break
self._clients.add(conn)
threading.Thread(target=self._handle, args=(conn, addr), daemon=True).start()
def _handle(self, conn, addr):
try:
_set_socket_opts(conn)
while not self.stopped.is_set():
header = recv_header(conn)
auth = header.get("auth", "")
if self.token and (not isinstance(auth, str) or not hmac.compare_digest(
auth.encode("utf-8"), self.token.encode("utf-8"))):
send_packet(conn, {"error": "unauthorized", "blob_size": 0})
break
if header.get("proto") != PROTOCOL_VERSION:
send_packet(conn, {"error": f"Protocol mismatch: install v{PROTOCOL_VERSION} on both ends", "blob_size": 0})
break
blob = recv_blob(conn, header)
try:
payload = unpack_object(header, blob)
if self.stopped.is_set():
break
out = self.worker.execute(header["cmd"], payload,
header.get("lora_stack", []), header.get("layer_idx"))
if self.stopped.is_set():
break
transport = header.get("transport_dtype")
if transport not in (None, "torch.float16", "torch.float32"):
raise ValueError("Invalid transport precision")
dtype = ALLOWED_DTYPES.get(transport) if header["cmd"] in ("encode", "encode_scheduled") else None
response, data = pack_object(out, dtype)
send_packet(conn, response, data)
except Exception as e:
if self.stopped.is_set():
break
log(f"Worker request failed: {e}")
send_packet(conn, {"error": str(e), "blob_size": 0})
except (ConnectionError, OSError, ValueError, TypeError, KeyError, struct.error) as e:
log(f"Client {addr} disconnected: {e}")
finally:
with self._clients_lock:
self._clients.discard(conn)
self._close_socket(conn)
class SendRemoteCLIP:
_servers = {}
_servers_lock = threading.Lock()
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"CLIP": ("CLIP",),
"listen_port": ("INT", {"default": DEFAULT_PORT, "min": 1, "max": 65535}),
}, "optional": {
"bind_host": ("STRING", {"default": "0.0.0.0"}),
"auth_token": ("STRING", {"default": ""}),
}}
RETURN_TYPES = ()
FUNCTION = "start_worker"
OUTPUT_NODE = True
CATEGORY = "Remote CLIP"
def start_worker(self, CLIP, listen_port, bind_host="0.0.0.0", auth_token=""):
token = auth_token or os.environ.get("REMOTE_CLIP_TOKEN", "")
with self._servers_lock:
previous = self._servers.pop(listen_port, None)
if previous is not None:
previous.close()
self._servers[listen_port] = _Server(CLIP, bind_host, listen_port, token)
if bind_host == "0.0.0.0" and not token:
log("WARNING: worker is accessible on all interfaces without authentication")
log(f"Worker listening on {bind_host}:{listen_port}")
return {"ui": {"text": [f"Remote CLIP worker on {bind_host}:{listen_port}"]}}
class LoadRemoteCLIP:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"worker_ip": ("STRING", {"default": "127.0.0.1"}),
"port": ("INT", {"default": DEFAULT_PORT, "min": 1, "max": 65535}),
}, "optional": {
"auth_token": ("STRING", {"default": ""}),
"transport_precision": (["auto", "fp16", "fp32"], {"default": "auto"}),
"connect_timeout": ("FLOAT", {"default": SOCKET_TIMEOUT, "min": 0.1, "max": 3600.0, "step": 0.1, "tooltip": "Seconds per connection attempt."}),
"request_attempts": ("INT", {"default": CONNECT_RETRIES, "min": 1, "max": 10, "tooltip": "Total attempts per operation, including the first. Connection failures and safe request retries share this single limit. Generation is never replayed after sending starts."}),
"connect_backoff": ("FLOAT", {"default": CONNECT_BACKOFF, "min": 0.0, "max": 60.0, "step": 0.1, "tooltip": "Initial retry delay in seconds; doubles after each failure."}),
"encode_timeout": ("FLOAT", {"default": SOCKET_TIMEOUT, "min": 0.1, "max": 86400.0, "step": 0.1, "tooltip": "Socket timeout for tokenization and encoding; not a total operation deadline."}),
"generate_timeout": ("FLOAT", {"default": GENERATE_TIMEOUT, "min": 0.1, "max": 86400.0, "step": 0.1, "tooltip": "Generation socket timeout; not a total operation deadline."}),
}}
RETURN_TYPES = ("CLIP",)
FUNCTION = "load_remote"
CATEGORY = "Remote CLIP"
def load_remote(self, worker_ip, port, auth_token="", transport_precision="auto",
connect_timeout=SOCKET_TIMEOUT, request_attempts=CONNECT_RETRIES,
connect_backoff=CONNECT_BACKOFF, encode_timeout=SOCKET_TIMEOUT,
generate_timeout=GENERATE_TIMEOUT):
token = auth_token or os.environ.get("REMOTE_CLIP_TOKEN", "")
return (RemoteCLIPProxy(worker_ip, port, token, transport_precision,
connect_timeout=connect_timeout, request_attempts=request_attempts,
connect_backoff=connect_backoff, encode_timeout=encode_timeout,
generate_timeout=generate_timeout),)
class LoraLoaderCLIPOnly:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"CLIP": ("CLIP",),
"lora_name": (folder_paths.get_filename_list("loras"),),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
}
}
RETURN_TYPES = ("CLIP",)
FUNCTION = "load_lora"
CATEGORY = "Remote CLIP"
def load_lora(self, CLIP, lora_name, strength_clip):
if isinstance(CLIP, RemoteCLIPProxy):
return (CLIP.with_lora(lora_name, strength_clip),)
if strength_clip == 0:
return (CLIP,)
lora_path = folder_paths.get_full_path("loras", lora_name)
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
_, clip_lora = comfy.sd.load_lora_for_models(None, CLIP, lora, 0, strength_clip)
return (clip_lora,)
NODE_CLASS_MAPPINGS = {
"SendRemoteCLIP": SendRemoteCLIP,
"LoadRemoteCLIP": LoadRemoteCLIP,
"LoraLoaderCLIPOnly": LoraLoaderCLIPOnly,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SendRemoteCLIP": "Send Remote CLIP",
"LoadRemoteCLIP": "Load Remote CLIP",
"LoraLoaderCLIPOnly": "LoraLoaderCLIPOnly",
}