6813 lines
285 KiB
Python
6813 lines
285 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""h4 Toolkit custom nodes for ComfyUI.
|
|
|
|
This module implements the Plot node (end-to-end sampler + grid generator with
|
|
progress previews) and the Debug-a-tron-3000 adaptive router/inspector. Both
|
|
nodes are instrumented with extremely verbose logging so that every action is
|
|
visible in the Stability Matrix / ComfyUI console during development.
|
|
|
|
The implementation leans on existing ComfyUI building blocks (samplers, VAE
|
|
utilities, CLIP encoders, etc.) to ensure forward compatibility while keeping
|
|
resource usage as low as practical. All helper utilities in this file are kept
|
|
lightweight and are re-used by both nodes to honour the Node policy.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import inspect
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import textwrap
|
|
import time
|
|
import weakref
|
|
from dataclasses import dataclass, field
|
|
import copy
|
|
import html
|
|
import secrets
|
|
import random
|
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
|
|
import hashlib
|
|
from datetime import datetime
|
|
|
|
import numpy as np
|
|
import torch
|
|
|
|
from colorama import Fore, Style
|
|
|
|
try: # pragma: no cover - optional preview module
|
|
import latent_preview
|
|
except ImportError: # pragma: no cover - preview is optional
|
|
latent_preview = None
|
|
|
|
try: # pragma: no cover - optional annotation dependency
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
except ImportError: # pragma: no cover - optional dependency
|
|
Image = None
|
|
ImageDraw = None
|
|
ImageFont = None
|
|
|
|
import comfy.model_management as model_management
|
|
import comfy.samplers as comfy_samplers
|
|
import comfy.sd as comfy_sd
|
|
import comfy.utils as comfy_utils
|
|
import folder_paths
|
|
import nodes as comfy_nodes
|
|
|
|
try: # pragma: no cover - optional runtime dependency
|
|
from comfy.cli_args import args as comfy_args # type: ignore[import]
|
|
except Exception: # pragma: no cover - args are optional in tests
|
|
comfy_args = None
|
|
|
|
try: # pragma: no cover - optional execution context info
|
|
from comfy_execution.utils import get_executing_context # type: ignore[import]
|
|
except Exception: # pragma: no cover - execution context disabled
|
|
get_executing_context = None
|
|
|
|
|
|
TOOLKIT_VERSION = "1.3.0"
|
|
PLOT_NODE_VERSION = "1.3.0"
|
|
DEBUG_NODE_VERSION = "2.1.0"
|
|
AXIS_DRIVER_VERSION = "0.6.0"
|
|
UTILITY_NODE_VERSION = "1.1.0"
|
|
VARIANATOR_VERSION = "1.0.0"
|
|
|
|
AXIS_DRIVER_MAX_ITEMS = 8
|
|
AXIS_DRIVER_SLOT_ORDER: Tuple[str, ...] = ("X", "Y", "Z")
|
|
AXIS_DRIVER_SUPPORTED_PRESETS: Tuple[str, ...] = (
|
|
"none",
|
|
"prompt",
|
|
"checkpoint",
|
|
"lora",
|
|
"sampler",
|
|
"scheduler",
|
|
"steps",
|
|
"cfg",
|
|
"denoise",
|
|
"seed",
|
|
)
|
|
|
|
AXIS_DRIVER_PRESET_DEFAULTS: Dict[str, str] = {
|
|
"X": "checkpoint",
|
|
"Y": "prompt",
|
|
"Z": "none",
|
|
}
|
|
|
|
AXIS_DRIVER_DEFAULT_STYLE: Dict[str, Any] = {
|
|
"font_size": 22,
|
|
"font_family": "DejaVuSans",
|
|
"font_colour": "#FFFFFF",
|
|
"background": "black60",
|
|
"alignment": "center",
|
|
"label_position": "top_left",
|
|
"label_layout": "overlay",
|
|
"custom_label_x": "X",
|
|
"custom_label_y": "Y",
|
|
"custom_label_z": "Z",
|
|
"show_axis_headers": True,
|
|
}
|
|
|
|
AXIS_DRIVER_DEFAULT_STATE: Dict[str, Any] = {
|
|
"axes": [
|
|
{"slot": "X", "preset": "checkpoint", "items": []},
|
|
{"slot": "Y", "preset": "prompt", "items": []},
|
|
{"slot": "Z", "preset": "none", "items": []},
|
|
],
|
|
"style": AXIS_DRIVER_DEFAULT_STYLE,
|
|
}
|
|
|
|
|
|
UI_DISABLED_TOKEN = "NONE"
|
|
|
|
|
|
TRACE_ENABLED = os.getenv("H4_TOOLKIT_TRACE", "0") not in {"0", "false", "False", ""}
|
|
|
|
_PROXY_REGISTRY = weakref.WeakKeyDictionary()
|
|
|
|
|
|
def _discover_sampler_choices() -> List[str]:
|
|
try:
|
|
raw = getattr(comfy_samplers.KSampler, "SAMPLERS", None) or []
|
|
except Exception: # pragma: no cover - environment specific
|
|
raw = []
|
|
|
|
names: List[str] = []
|
|
if isinstance(raw, dict):
|
|
names = list(raw.keys())
|
|
else:
|
|
for item in raw:
|
|
if isinstance(item, (list, tuple)) and item:
|
|
names.append(str(item[0]))
|
|
elif isinstance(item, str):
|
|
names.append(item)
|
|
|
|
if not names:
|
|
names = [
|
|
"euler",
|
|
"euler_ancestral",
|
|
"lms",
|
|
"heun",
|
|
"dpmpp_2m",
|
|
"dpmpp_2m_sde",
|
|
"dpmpp_sde",
|
|
"ddim",
|
|
"ddpm",
|
|
]
|
|
|
|
seen = set()
|
|
ordered: List[str] = []
|
|
for name in names:
|
|
if name not in seen:
|
|
ordered.append(name)
|
|
seen.add(name)
|
|
return ordered
|
|
|
|
|
|
def _discover_scheduler_choices() -> List[str]:
|
|
try:
|
|
raw = getattr(comfy_samplers, "SCHEDULERS", None) or []
|
|
except Exception: # pragma: no cover - environment specific
|
|
raw = []
|
|
|
|
names: List[str] = []
|
|
if isinstance(raw, dict):
|
|
names = list(raw.keys())
|
|
else:
|
|
for item in raw:
|
|
if isinstance(item, (list, tuple)) and item:
|
|
names.append(str(item[0]))
|
|
elif isinstance(item, str):
|
|
names.append(item)
|
|
|
|
if not names:
|
|
names = [
|
|
"normal",
|
|
"simple",
|
|
"karras",
|
|
"exponential",
|
|
"sgm_uniform",
|
|
"lognormal",
|
|
]
|
|
|
|
seen = set()
|
|
ordered: List[str] = []
|
|
for name in names:
|
|
if name not in seen:
|
|
ordered.append(name)
|
|
seen.add(name)
|
|
return ordered
|
|
|
|
|
|
SAMPLER_CHOICES = tuple(_discover_sampler_choices())
|
|
SAMPLER_LOOKUP = {
|
|
key: choice
|
|
for choice in SAMPLER_CHOICES
|
|
for key in {
|
|
choice.lower(),
|
|
choice.replace(" ", "_").lower(),
|
|
choice.replace("_", "").lower(),
|
|
}
|
|
}
|
|
|
|
|
|
SCHEDULER_CHOICES = tuple(_discover_scheduler_choices())
|
|
SCHEDULER_LOOKUP = {
|
|
key: choice
|
|
for choice in SCHEDULER_CHOICES
|
|
for key in {
|
|
choice.lower(),
|
|
choice.replace(" ", "_").lower(),
|
|
choice.replace("_", "").lower(),
|
|
}
|
|
}
|
|
|
|
|
|
def _invoke_sampler_with_compatibility(
|
|
logger: ToolkitLogger, sampler_kwargs: Dict[str, Any]
|
|
) -> Dict[str, torch.Tensor]:
|
|
sampler_fn = getattr(comfy_samplers, "sample", None)
|
|
if sampler_fn is None:
|
|
raise RuntimeError("ComfyUI sampler entrypoint is unavailable")
|
|
|
|
try:
|
|
signature = inspect.signature(sampler_fn)
|
|
except (TypeError, ValueError):
|
|
signature = None
|
|
|
|
rename_priority = {
|
|
"steps": ("num_steps", "step_count", "steps"),
|
|
"cfg": ("cfg", "cfg_scale", "guidance_scale"),
|
|
"sampler_name": ("sampler_name", "sampler"),
|
|
"scheduler_name": ("scheduler_name", "scheduler"),
|
|
"denoise": ("denoise", "denoise_strength"),
|
|
"positive": ("positive", "positive_conditioning"),
|
|
"negative": ("negative", "negative_conditioning"),
|
|
"latent": ("latent_image", "latent"),
|
|
"callback": ("callback", "step_callback"),
|
|
"device": ("device", "model_device"),
|
|
"sigmas": ("sigmas", "sigma_schedule"),
|
|
"model_sampling": ("model_sampling", "model_sampler"),
|
|
}
|
|
|
|
if signature is None:
|
|
filtered_direct = {key: value for key, value in sampler_kwargs.items() if value is not None}
|
|
try:
|
|
return sampler_fn(**filtered_direct)
|
|
except TypeError as exc: # pragma: no cover - best effort fallback
|
|
logger.warn(f"Sampler call signature mismatch; retrying with raw kwargs: {exc}")
|
|
return sampler_fn(**sampler_kwargs)
|
|
|
|
params = signature.parameters
|
|
accepts_kwargs = any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values())
|
|
filtered_kwargs: Dict[str, Any] = {}
|
|
renamed: Dict[str, str] = {}
|
|
dropped: List[str] = []
|
|
|
|
for key, value in sampler_kwargs.items():
|
|
aliases = rename_priority.get(key, (key,))
|
|
chosen: Optional[str] = None
|
|
for alias in aliases:
|
|
if alias in params:
|
|
chosen = alias
|
|
break
|
|
if chosen is None and key in params:
|
|
chosen = key
|
|
if chosen is not None:
|
|
filtered_kwargs[chosen] = value
|
|
if chosen != key:
|
|
renamed[key] = chosen
|
|
elif accepts_kwargs:
|
|
filtered_kwargs[key] = value
|
|
else:
|
|
dropped.append(key)
|
|
|
|
missing_required = [
|
|
name
|
|
for name, param in params.items()
|
|
if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
|
and param.default is inspect._empty
|
|
and name not in filtered_kwargs
|
|
]
|
|
|
|
if dropped:
|
|
logger.trace(
|
|
"Sampler compatibility shim dropped unsupported kwargs: " + ", ".join(sorted(dropped))
|
|
)
|
|
if "sigmas" in dropped:
|
|
logger.warn(
|
|
"Sampler compatibility shim discarded the sigma schedule parameter; sampler will recalc on its default device"
|
|
)
|
|
if renamed:
|
|
details = ", ".join(f"{orig}->{alias}" for orig, alias in sorted(renamed.items()))
|
|
logger.trace(f"Sampler compatibility shim remapped kwargs: {details}")
|
|
if missing_required:
|
|
logger.warn(
|
|
"Sampler compatibility shim missing required parameters after filtering: "
|
|
+ ", ".join(missing_required)
|
|
)
|
|
# fall back to the raw call so the original error bubbles with context
|
|
return sampler_fn(**sampler_kwargs)
|
|
|
|
try:
|
|
return sampler_fn(**filtered_kwargs)
|
|
except TypeError as exc:
|
|
message = exc.args[0] if exc.args else ""
|
|
if isinstance(message, str) and "unexpected keyword argument" in message:
|
|
logger.warn(f"Sampler compatibility shim retrying with raw kwargs due to signature mismatch: {message}")
|
|
return sampler_fn(**sampler_kwargs)
|
|
raise
|
|
|
|
|
|
class _DeviceAlignedDiffusionProxy:
|
|
__slots__ = ("_inner", "_device", "_logger", "_sigma_fix_logged", "_call_logged", "__weakref__")
|
|
|
|
def __init__(self, inner: Any, device: torch.device, logger: ToolkitLogger) -> None:
|
|
object.__setattr__(self, "_inner", inner)
|
|
object.__setattr__(self, "_device", device)
|
|
object.__setattr__(self, "_logger", logger)
|
|
object.__setattr__(self, "_sigma_fix_logged", False)
|
|
object.__setattr__(self, "_call_logged", False)
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
return getattr(object.__getattribute__(self, "_inner"), name)
|
|
|
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
setattr(object.__getattribute__(self, "_inner"), name, value)
|
|
|
|
def _coerce_timestep(self, timestep: Any, reference: Optional[torch.Tensor]) -> Any:
|
|
device = object.__getattribute__(self, "_device")
|
|
logger = object.__getattribute__(self, "_logger")
|
|
logged = object.__getattribute__(self, "_sigma_fix_logged")
|
|
if isinstance(timestep, torch.Tensor):
|
|
if timestep.device != device:
|
|
coerced = timestep.to(device=device)
|
|
if not logged:
|
|
logger.warn(f"Diffusion timestep migrated {timestep.device} -> {device}")
|
|
object.__setattr__(self, "_sigma_fix_logged", True)
|
|
return coerced
|
|
return timestep
|
|
if isinstance(timestep, (float, int)):
|
|
dtype = reference.dtype if isinstance(reference, torch.Tensor) else torch.float32
|
|
return torch.as_tensor(timestep, dtype=dtype, device=device)
|
|
if hasattr(timestep, "to"):
|
|
try:
|
|
coerced = timestep.to(device=device)
|
|
if not logged:
|
|
logger.warn("Diffusion timestep coerced to device via .to()")
|
|
object.__setattr__(self, "_sigma_fix_logged", True)
|
|
return coerced
|
|
except Exception:
|
|
pass
|
|
return timestep
|
|
|
|
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
inner = object.__getattribute__(self, "_inner")
|
|
args_list = list(args)
|
|
reference_tensor: Optional[torch.Tensor] = None
|
|
if args_list and isinstance(args_list[0], torch.Tensor):
|
|
reference_tensor = args_list[0]
|
|
elif isinstance(kwargs.get("xc"), torch.Tensor):
|
|
reference_tensor = kwargs["xc"]
|
|
timestep_value: Any = None
|
|
timestep_index: Optional[int] = None
|
|
if len(args_list) >= 2:
|
|
timestep_index = 1
|
|
timestep_value = args_list[1]
|
|
elif "t" in kwargs:
|
|
timestep_value = kwargs["t"]
|
|
elif "timestep" in kwargs:
|
|
timestep_value = kwargs["timestep"]
|
|
logger = object.__getattribute__(self, "_logger")
|
|
call_logged = object.__getattribute__(self, "_call_logged")
|
|
if not call_logged:
|
|
desc: str
|
|
if isinstance(timestep_value, torch.Tensor):
|
|
desc = f"tensor[{timestep_value.device}]"
|
|
else:
|
|
desc = type(timestep_value).__name__
|
|
logger.info(f"[DeviceCheck] diffusion_model inbound timestep={desc}")
|
|
object.__setattr__(self, "_call_logged", True)
|
|
if timestep_value is not None:
|
|
coerced = self._coerce_timestep(timestep_value, reference_tensor)
|
|
if timestep_index is not None:
|
|
args_list[timestep_index] = coerced
|
|
elif "t" in kwargs:
|
|
kwargs["t"] = coerced
|
|
else:
|
|
kwargs["timestep"] = coerced
|
|
return inner(*tuple(args_list), **kwargs)
|
|
|
|
def update_device(self, device: torch.device) -> None:
|
|
object.__setattr__(self, "_device", device)
|
|
object.__setattr__(self, "_sigma_fix_logged", False)
|
|
object.__setattr__(self, "_call_logged", False)
|
|
|
|
|
|
# ====================================================================================================
|
|
# Device Alignment Proxies
|
|
# ====================================================================================================
|
|
class _TimeEmbedProxy(torch.nn.Module):
|
|
"""A proxy specifically for the time_embed module to ensure its input is on the correct device."""
|
|
def __init__(self, inner: torch.nn.Module, device: torch.device, logger: ToolkitLogger):
|
|
super().__init__()
|
|
self.inner = inner
|
|
self.device = device
|
|
self.logger = logger
|
|
self._forward_logged = False
|
|
|
|
def forward(self, temb: torch.Tensor) -> torch.Tensor:
|
|
self.inner.to(self.device)
|
|
if not self._forward_logged:
|
|
self.logger.info("[DeviceCheck] time_embed proxy validating payload devices")
|
|
self._forward_logged = True
|
|
if temb.device != self.device:
|
|
self.logger.warn(f"[DeviceCheck] Coercing time_embed input from {temb.device} to {self.device}")
|
|
temb = temb.to(self.device)
|
|
return self.inner(temb)
|
|
|
|
def update_device(self, device: torch.device) -> None:
|
|
self.device = device
|
|
self.inner.to(device)
|
|
self._forward_logged = False
|
|
|
|
|
|
class _ModuleDeviceProxy(torch.nn.Module):
|
|
"""Generic proxy that keeps a child module and its tensor inputs on a specific device."""
|
|
|
|
def __init__(
|
|
self,
|
|
inner: torch.nn.Module,
|
|
device: torch.device,
|
|
logger: ToolkitLogger,
|
|
module_label: str,
|
|
) -> None:
|
|
super().__init__()
|
|
self.inner = inner
|
|
self.device = device
|
|
self.logger = logger
|
|
self.module_label = module_label
|
|
self._logged = False
|
|
|
|
def _coerce_payload(self, value: Any) -> Any:
|
|
return _DeviceAlignedModelProxy._payload_to_device(
|
|
value,
|
|
self.device,
|
|
logger=self.logger,
|
|
allow_dtype_adjustment=self.device.type == "cuda",
|
|
)
|
|
|
|
def forward(self, *args: Any, **kwargs: Any) -> Any:
|
|
self.inner.to(self.device)
|
|
if not self._logged:
|
|
self.logger.info(f"[DeviceCheck] {self.module_label} proxy enforcing device alignment on {self.device}")
|
|
self._logged = True
|
|
coerced_args = tuple(self._coerce_payload(arg) for arg in args)
|
|
coerced_kwargs = {key: self._coerce_payload(val) for key, val in kwargs.items()}
|
|
return self.inner(*coerced_args, **coerced_kwargs)
|
|
|
|
def update_device(self, device: torch.device) -> None:
|
|
self.device = device
|
|
self.inner.to(device)
|
|
self._logged = False
|
|
|
|
|
|
class _DeviceAlignedModelProxy(torch.nn.Module):
|
|
_calc_patch_applied: bool = False
|
|
_calc_cond_batch_original: Optional[Callable[..., Any]] = None
|
|
|
|
def __init__(self, inner: Any, device: torch.device, logger: ToolkitLogger) -> None:
|
|
super().__init__()
|
|
# Use a direct assignment to a private attribute to avoid torch.nn.Module's magic
|
|
self._inner = inner
|
|
self._device = device
|
|
self._logger = logger
|
|
self._sigma_fix_logged = False
|
|
self._apply_logged = False
|
|
self._child_proxies: Dict[str, "_DeviceAlignedModelProxy"] = {}
|
|
self._delegated_attributes: Set[str] = set()
|
|
self._module_proxies: List[torch.nn.Module] = []
|
|
self._module_hook_targets: List[torch.nn.Module] = []
|
|
|
|
logger.info(
|
|
f"[DeviceCheck] installing model proxy for {inner.__class__.__name__} on {device}"
|
|
)
|
|
self._ensure_calc_patch(logger)
|
|
|
|
# Landmark: Primary Module Device Sync
|
|
self._move_target_to_device(self._inner, device, inner.__class__.__name__)
|
|
|
|
# Landmark: Time Embed Proxy Injection
|
|
# The time_embed module is often on a child object (e.g., diffusion_model), not the top-level model.
|
|
# We need to find it and patch it there.
|
|
target_for_patch = None
|
|
diffusion_model = getattr(self._inner, "diffusion_model", None)
|
|
if isinstance(diffusion_model, torch.nn.Module):
|
|
self._move_target_to_device(diffusion_model, device, "diffusion_model")
|
|
if diffusion_model is not None and hasattr(diffusion_model, "time_embed"):
|
|
logger.info("[DeviceCheck] Found diffusion_model, targeting it for time_embed patch.")
|
|
target_for_patch = diffusion_model
|
|
elif hasattr(self._inner, "time_embed"):
|
|
logger.info("[DeviceCheck] Found time_embed on main model, targeting it for patch.")
|
|
target_for_patch = self._inner
|
|
|
|
if target_for_patch:
|
|
time_embed_module = getattr(target_for_patch, "time_embed", None)
|
|
if time_embed_module is not None and isinstance(time_embed_module, torch.nn.Module):
|
|
if not isinstance(time_embed_module, _TimeEmbedProxy):
|
|
try:
|
|
# Directly replace the attribute on the target module
|
|
proxy = _TimeEmbedProxy(time_embed_module, device, logger)
|
|
setattr(target_for_patch, "time_embed", proxy)
|
|
logger.info(f"[DeviceCheck] Time embed proxy installed on {target_for_patch.__class__.__name__}.")
|
|
self._module_proxies.append(proxy)
|
|
|
|
except Exception as exc:
|
|
logger.warn(f"Failed to install time_embed proxy: {exc}")
|
|
else:
|
|
time_embed_module.update_device(device)
|
|
logger.info("[DeviceCheck] Time embed proxy was already installed.")
|
|
if time_embed_module not in self._module_proxies:
|
|
self._module_proxies.append(time_embed_module)
|
|
else:
|
|
logger.info("[DeviceCheck] Target for patch has no time_embed module or it's not a torch.nn.Module.")
|
|
else:
|
|
logger.info("[DeviceCheck] Could not find a suitable target for time_embed proxy installation.")
|
|
|
|
# Landmark: Label Embedding Proxy Injection
|
|
label_target = None
|
|
if diffusion_model is not None and hasattr(diffusion_model, "label_emb"):
|
|
label_target = diffusion_model
|
|
elif hasattr(self._inner, "label_emb"):
|
|
label_target = self._inner
|
|
if label_target is not None:
|
|
self._install_generic_module_proxy(label_target, "label_emb", "label_emb")
|
|
else:
|
|
logger.info("[DeviceCheck] No label_emb attribute detected for proxy installation.")
|
|
|
|
# Landmark: Diffusion Block Proxy Installation
|
|
if diffusion_model is not None:
|
|
self._install_block_proxies(diffusion_model)
|
|
else:
|
|
self._install_block_proxies(self._inner)
|
|
|
|
# Explicitly wrap the diffusion_model if it exists and isn't already a proxy
|
|
diffusion_model = getattr(self._inner, "diffusion_model", None)
|
|
if diffusion_model is not None and not isinstance(diffusion_model, _DeviceAlignedModelProxy):
|
|
try:
|
|
# This is a 'child' module, so we assign it directly.
|
|
# PyTorch's __setattr__ will register it.
|
|
self.diffusion_model = _DeviceAlignedModelProxy(diffusion_model, device, logger)
|
|
except Exception as exc:
|
|
logger.warn(f"Failed to monkey-patch diffusion_model attribute: {exc}")
|
|
|
|
self._wrap_known_children()
|
|
try:
|
|
_PROXY_REGISTRY[inner] = self
|
|
except TypeError:
|
|
pass
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
# Avoid recursion errors with nn.Module's internal attributes
|
|
if name.startswith("_"):
|
|
raise AttributeError(f"Private attribute '{name}' not found")
|
|
|
|
# Check for our custom attributes first
|
|
if name in {"_inner", "_device", "_logger", "_sigma_fix_logged", "_apply_logged", "_child_proxies", "_delegated_attributes", "_module_proxies", "_module_hook_targets"}:
|
|
return self.__dict__[name]
|
|
|
|
# If it's a proxied child, return it
|
|
if name in self._child_proxies:
|
|
return self._child_proxies[name]
|
|
|
|
# Special handling for common model attributes to ensure they are wrapped
|
|
if name in {"inner_model", "model", "wrapped_model", "wrapped", "model_inner", "model_core"}:
|
|
return self._resolve_child_attribute(name)
|
|
|
|
# Finally, delegate to the inner object
|
|
return getattr(self._inner, name)
|
|
|
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
# Prevent torch.nn.Module from hijacking our private attributes
|
|
if name in {"_inner", "_device", "_logger", "_sigma_fix_logged", "_apply_logged", "_child_proxies", "_delegated_attributes", "_module_proxies", "_module_hook_targets"}:
|
|
self.__dict__[name] = value
|
|
# If the value is a Module, let the parent class handle it to register it properly
|
|
elif isinstance(value, torch.nn.Module):
|
|
super().__setattr__(name, value)
|
|
# Otherwise, set it on the inner object
|
|
else:
|
|
setattr(self._inner, name, value)
|
|
|
|
def update_device(self, device: torch.device) -> None:
|
|
self._device = device
|
|
for proxy in self._child_proxies.values():
|
|
proxy.update_device(device)
|
|
# Also update device of registered sub-modules that are proxies
|
|
for module in self.children():
|
|
if isinstance(module, _DeviceAlignedModelProxy):
|
|
module.update_device(device)
|
|
for module_proxy in list(self._module_proxies):
|
|
update_fn = getattr(module_proxy, "update_device", None)
|
|
if callable(update_fn):
|
|
update_fn(device)
|
|
elif hasattr(module_proxy, "_h4_device_alignment"):
|
|
setattr(module_proxy, "_h4_device_alignment_logged", False)
|
|
setattr(module_proxy, "_h4_device_alignment_device", device)
|
|
for module in list(self._module_hook_targets):
|
|
if hasattr(module, "_h4_device_alignment"):
|
|
setattr(module, "_h4_device_alignment_logged", False)
|
|
setattr(module, "_h4_device_alignment_device", device)
|
|
self._move_target_to_device(module, device, getattr(module, "_h4_device_alignment_label", module.__class__.__name__))
|
|
self._move_target_to_device(self._inner, device, self._inner.__class__.__name__)
|
|
diffusion_model = getattr(self._inner, "diffusion_model", None)
|
|
self._move_target_to_device(diffusion_model, device, "diffusion_model")
|
|
self._sigma_fix_logged = False
|
|
self._apply_logged = False
|
|
|
|
def _install_generic_module_proxy(self, host: Any, attribute: str, label: str) -> None:
|
|
logger = self._logger
|
|
if host is None:
|
|
return
|
|
module = getattr(host, attribute, None)
|
|
if module is None or not isinstance(module, torch.nn.Module):
|
|
logger.info(f"[DeviceCheck] {label} proxy skipped; attribute missing or not a module.")
|
|
return
|
|
if isinstance(module, _ModuleDeviceProxy):
|
|
module.update_device(self._device)
|
|
logger.info(f"[DeviceCheck] {label} proxy already active on {host.__class__.__name__}.")
|
|
if module not in self._module_proxies:
|
|
self._module_proxies.append(module)
|
|
return
|
|
try:
|
|
proxy = _ModuleDeviceProxy(module, self._device, logger, label)
|
|
setattr(host, attribute, proxy)
|
|
self._module_proxies.append(proxy)
|
|
logger.info(f"[DeviceCheck] {label} proxy installed on {host.__class__.__name__}.")
|
|
except Exception as exc:
|
|
logger.warn(f"[DeviceCheck] Failed installing {label} proxy on {host.__class__.__name__}: {exc}")
|
|
|
|
def _move_target_to_device(self, target: Any, device: torch.device, label: str) -> None:
|
|
if target is None:
|
|
return
|
|
if not isinstance(target, torch.nn.Module):
|
|
return
|
|
move_to = getattr(target, "to", None)
|
|
if not callable(move_to):
|
|
return
|
|
try:
|
|
move_to(device)
|
|
except Exception as exc:
|
|
self._logger.warn(f"[DeviceCheck] Unable to move {label} to {device}: {exc}")
|
|
return
|
|
self._realign_module_parameters(target, device, label)
|
|
|
|
def _realign_module_parameters(self, target: torch.nn.Module, device: torch.device, label: str, depth: int = 0, visited: Optional[Set[int]] = None) -> None:
|
|
if visited is None:
|
|
visited = set()
|
|
target_id = id(target)
|
|
if target_id in visited:
|
|
return
|
|
visited.add(target_id)
|
|
moved_any = False
|
|
for name, parameter in list(target._parameters.items()): # type: ignore[attr-defined]
|
|
if parameter is None:
|
|
continue
|
|
if parameter.device != device:
|
|
parameter.data = parameter.data.to(device=device)
|
|
moved_any = True
|
|
for name, buffer in list(target._buffers.items()): # type: ignore[attr-defined]
|
|
if buffer is None:
|
|
continue
|
|
if hasattr(buffer, "device") and buffer.device != device:
|
|
target._buffers[name] = buffer.to(device=device) # type: ignore[attr-defined]
|
|
moved_any = True
|
|
if moved_any and depth == 0:
|
|
self._logger.info(f"[DeviceCheck] {label} parameters realigned to {device}")
|
|
for child in target.children():
|
|
if isinstance(child, torch.nn.Module):
|
|
self._realign_module_parameters(child, device, label, depth + 1, visited)
|
|
|
|
def _install_block_proxies(self, host: Any) -> None:
|
|
if host is None:
|
|
return
|
|
block_specs = (
|
|
("input_blocks", "input_block"),
|
|
("middle_block", "middle_block"),
|
|
("output_blocks", "output_block"),
|
|
)
|
|
for attr, label_prefix in block_specs:
|
|
blocks = getattr(host, attr, None)
|
|
if blocks is None:
|
|
continue
|
|
if isinstance(blocks, (torch.nn.ModuleList, torch.nn.Sequential, list, tuple)):
|
|
iterator = enumerate(blocks)
|
|
else:
|
|
iterator = ((None, blocks),)
|
|
for index, block in iterator:
|
|
label = f"{label_prefix}[{index}]" if index is not None else label_prefix
|
|
self._ensure_module_hook(block, label)
|
|
|
|
def _ensure_module_hook(self, module: Any, label: str) -> None:
|
|
if module is None or not isinstance(module, torch.nn.Module):
|
|
return
|
|
if module in self._module_hook_targets:
|
|
setattr(module, "_h4_device_alignment_logged", False)
|
|
setattr(module, "_h4_device_alignment_device", self._device)
|
|
return
|
|
|
|
logger = self._logger
|
|
owner_ref = weakref.ref(self)
|
|
static_label = label
|
|
self._move_target_to_device(module, self._device, static_label)
|
|
|
|
def _pre_hook(mod: torch.nn.Module, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Tuple[Tuple[Any, ...], Dict[str, Any]]:
|
|
owner = owner_ref()
|
|
if owner is None:
|
|
return args, kwargs
|
|
device = getattr(mod, "_h4_device_alignment_device", owner._device)
|
|
mod.to(device)
|
|
if not getattr(mod, "_h4_device_alignment_logged", False):
|
|
logger.info(f"[DeviceCheck] {static_label} hook enforcing device alignment on {device}")
|
|
setattr(mod, "_h4_device_alignment_logged", True)
|
|
coerced_args = tuple(_DeviceAlignedModelProxy._payload_to_device(arg, device) for arg in args)
|
|
coerced_kwargs = {key: _DeviceAlignedModelProxy._payload_to_device(val, device) for key, val in kwargs.items()}
|
|
return coerced_args, coerced_kwargs
|
|
|
|
handle = module.register_forward_pre_hook(_pre_hook, with_kwargs=True)
|
|
module._h4_device_alignment = handle
|
|
module._h4_device_alignment_logged = False
|
|
module._h4_device_alignment_label = static_label
|
|
module._h4_device_alignment_device = self._device
|
|
self._module_hook_targets.append(module)
|
|
|
|
@staticmethod
|
|
def _eligible_for_wrapping(candidate: Any) -> bool:
|
|
if candidate is None:
|
|
return False
|
|
if isinstance(candidate, _DeviceAlignedModelProxy):
|
|
return True
|
|
if isinstance(candidate, torch.nn.Module):
|
|
return True
|
|
if hasattr(candidate, "apply_model") and callable(getattr(candidate, "apply_model")):
|
|
return True
|
|
if hasattr(candidate, "diffusion_model"):
|
|
return True
|
|
if hasattr(candidate, "model") and hasattr(candidate, "inner_model"):
|
|
return True
|
|
return False
|
|
|
|
def _obtain_child_proxy(self, candidate: Any, device: torch.device, logger: ToolkitLogger) -> Optional["_DeviceAlignedModelProxy"]:
|
|
if candidate is None:
|
|
return None
|
|
if candidate is self._inner:
|
|
return None
|
|
if isinstance(candidate, _DeviceAlignedModelProxy):
|
|
candidate.update_device(device)
|
|
return candidate
|
|
if not _DeviceAlignedModelProxy._eligible_for_wrapping(candidate):
|
|
return None
|
|
proxy: Optional[_DeviceAlignedModelProxy] = None
|
|
try:
|
|
proxy = _PROXY_REGISTRY.get(candidate)
|
|
except TypeError:
|
|
proxy = None
|
|
if proxy is not None:
|
|
proxy.update_device(device)
|
|
return proxy
|
|
proxy = _DeviceAlignedModelProxy(candidate, device, logger)
|
|
try:
|
|
_PROXY_REGISTRY[candidate] = proxy
|
|
except TypeError:
|
|
pass
|
|
return proxy
|
|
|
|
def _register_child_proxy(self, attr: str, proxy: "_DeviceAlignedModelProxy") -> None:
|
|
self._child_proxies[attr] = proxy
|
|
self._delegated_attributes.add(attr)
|
|
try:
|
|
# Use super's setattr to register it as a submodule
|
|
super().__setattr__(attr, proxy)
|
|
except Exception:
|
|
pass
|
|
|
|
def _wrap_known_children(self) -> None:
|
|
device = self._device
|
|
logger = self._logger
|
|
candidate_attrs = (
|
|
"inner_model",
|
|
"model",
|
|
"wrapped_model",
|
|
"wrapped",
|
|
"model_inner",
|
|
"model_core",
|
|
)
|
|
for attr in candidate_attrs:
|
|
if not hasattr(self._inner, attr):
|
|
continue
|
|
target = getattr(self._inner, attr)
|
|
proxy = self._obtain_child_proxy(target, device, logger)
|
|
if proxy is None:
|
|
continue
|
|
self._register_child_proxy(attr, proxy)
|
|
break
|
|
|
|
def _resolve_child_attribute(self, name: str) -> Any:
|
|
if name in self._child_proxies:
|
|
return self._child_proxies[name]
|
|
if not hasattr(self._inner, name):
|
|
raise AttributeError(name)
|
|
device = self._device
|
|
logger = self._logger
|
|
target = getattr(self._inner, name)
|
|
proxy = self._obtain_child_proxy(target, device, logger)
|
|
if proxy is not None:
|
|
self._register_child_proxy(name, proxy)
|
|
return proxy
|
|
return target
|
|
|
|
def _coerce_sigma(self, sigma: Any, reference: Optional[torch.Tensor]) -> Any:
|
|
device = self._device
|
|
logger = self._logger
|
|
fixed_flag = self._sigma_fix_logged
|
|
if isinstance(sigma, torch.Tensor):
|
|
if sigma.device != device:
|
|
coerced = sigma.to(device=device)
|
|
if not fixed_flag:
|
|
logger.warn(
|
|
f"Sampler sigma tensor arrived on {sigma.device}; moved to {device}"
|
|
)
|
|
self._sigma_fix_logged = True
|
|
return coerced
|
|
return sigma
|
|
if isinstance(sigma, (float, int)):
|
|
dtype = reference.dtype if isinstance(reference, torch.Tensor) else torch.float32
|
|
return torch.as_tensor(sigma, dtype=dtype, device=device)
|
|
if hasattr(sigma, "to"):
|
|
try:
|
|
coerced_any = sigma.to(device=device)
|
|
if hasattr(coerced_any, "device") and getattr(coerced_any, "device") != device:
|
|
coerced_any = torch.as_tensor(coerced_any, device=device)
|
|
if not fixed_flag:
|
|
logger.warn(
|
|
f"Sampler sigma object coerced onto {device} via .to() conversion"
|
|
)
|
|
self._sigma_fix_logged = True
|
|
return coerced_any
|
|
except Exception:
|
|
pass
|
|
return sigma
|
|
|
|
def _align_sigma_args(
|
|
self,
|
|
args: List[Any],
|
|
kwargs: Dict[str, Any],
|
|
input_ref: Optional[torch.Tensor],
|
|
) -> Tuple[List[Any], Dict[str, Any]]:
|
|
logger = self._logger
|
|
device = self._device
|
|
sigma_value: Any = None
|
|
sigma_index: Optional[int] = None
|
|
if len(args) >= 2:
|
|
sigma_index = 1
|
|
sigma_value = args[1]
|
|
elif "sigma" in kwargs:
|
|
sigma_value = kwargs["sigma"]
|
|
elif "timestep" in kwargs:
|
|
sigma_value = kwargs["timestep"]
|
|
apply_logged = self._apply_logged
|
|
if not apply_logged:
|
|
inbound_desc: str
|
|
if isinstance(sigma_value, torch.Tensor):
|
|
inbound_desc = f"tensor[{sigma_value.device}]"
|
|
else:
|
|
inbound_desc = type(sigma_value).__name__
|
|
logger.info(f"[DeviceCheck] apply_model inbound sigma={inbound_desc}")
|
|
self._apply_logged = True
|
|
if sigma_value is not None:
|
|
before_device: Optional[str] = None
|
|
if isinstance(sigma_value, torch.Tensor):
|
|
before_device = str(sigma_value.device)
|
|
sigma_value = self._coerce_sigma(sigma_value, input_ref)
|
|
if isinstance(sigma_value, torch.Tensor):
|
|
after_device = str(sigma_value.device)
|
|
if before_device is not None and before_device != after_device:
|
|
logger.info(
|
|
f"[DeviceCheck] sigma realigned {before_device} -> {after_device}"
|
|
)
|
|
if sigma_index is not None:
|
|
args[sigma_index] = sigma_value
|
|
elif "sigma" in kwargs:
|
|
kwargs["sigma"] = sigma_value
|
|
else:
|
|
kwargs["timestep"] = sigma_value
|
|
else:
|
|
logger.info(
|
|
f"[DeviceCheck] sigma parameter missing; args={len(args)} kwargs={list(kwargs.keys())}"
|
|
)
|
|
if isinstance(sigma_value, torch.Tensor) and sigma_value.device != device:
|
|
logger.warn(
|
|
f"[DeviceCheck] sigma remains on {sigma_value.device} expected {device}"
|
|
)
|
|
return args, kwargs
|
|
|
|
@staticmethod
|
|
def _payload_to_device(
|
|
value: Any,
|
|
device: torch.device,
|
|
*,
|
|
logger: Optional[ToolkitLogger] = None,
|
|
allow_dtype_adjustment: bool = False,
|
|
) -> Any:
|
|
if value is None:
|
|
return None
|
|
if torch.is_tensor(value):
|
|
return _DeviceAlignedModelProxy._tensor_to_device(
|
|
value,
|
|
device,
|
|
logger=logger,
|
|
allow_dtype_adjustment=allow_dtype_adjustment,
|
|
)
|
|
if isinstance(value, dict):
|
|
return {
|
|
key: _DeviceAlignedModelProxy._payload_to_device(
|
|
item,
|
|
device,
|
|
logger=logger,
|
|
allow_dtype_adjustment=allow_dtype_adjustment,
|
|
)
|
|
for key, item in value.items()
|
|
}
|
|
if isinstance(value, list):
|
|
return [
|
|
_DeviceAlignedModelProxy._payload_to_device(
|
|
item,
|
|
device,
|
|
logger=logger,
|
|
allow_dtype_adjustment=allow_dtype_adjustment,
|
|
)
|
|
for item in value
|
|
]
|
|
if isinstance(value, tuple):
|
|
return tuple(
|
|
_DeviceAlignedModelProxy._payload_to_device(
|
|
item,
|
|
device,
|
|
logger=logger,
|
|
allow_dtype_adjustment=allow_dtype_adjustment,
|
|
)
|
|
for item in value
|
|
)
|
|
if isinstance(value, set):
|
|
return {
|
|
_DeviceAlignedModelProxy._payload_to_device(
|
|
item,
|
|
device,
|
|
logger=logger,
|
|
allow_dtype_adjustment=allow_dtype_adjustment,
|
|
)
|
|
for item in value
|
|
}
|
|
move_to = getattr(value, "to", None)
|
|
if callable(move_to):
|
|
try:
|
|
return move_to(device=device)
|
|
except TypeError:
|
|
try:
|
|
return move_to(device)
|
|
except Exception:
|
|
pass
|
|
except torch.cuda.OutOfMemoryError as exc:
|
|
if device.type == "cuda" and allow_dtype_adjustment:
|
|
if logger is not None:
|
|
logger.warn(f"[DeviceCheck] Payload move to {device} hit OOM ({exc}); retrying with dtype adjustment")
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
try:
|
|
return move_to(device=device, dtype=torch.float16)
|
|
except (TypeError, torch.cuda.OutOfMemoryError):
|
|
pass
|
|
raise
|
|
except Exception:
|
|
pass
|
|
return value
|
|
|
|
@staticmethod
|
|
def _tensor_to_device(
|
|
tensor: torch.Tensor,
|
|
device: torch.device,
|
|
*,
|
|
logger: Optional[ToolkitLogger] = None,
|
|
allow_dtype_adjustment: bool = False,
|
|
) -> torch.Tensor:
|
|
if tensor.device == device:
|
|
return tensor
|
|
try:
|
|
return tensor.to(device=device, non_blocking=True)
|
|
except torch.cuda.OutOfMemoryError as exc:
|
|
if device.type != "cuda":
|
|
raise
|
|
if logger is not None:
|
|
logger.warn(f"[DeviceCheck] Tensor move to {device} failed ({exc}); attempting recovery")
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
if allow_dtype_adjustment and tensor.is_floating_point() and tensor.dtype != torch.float16:
|
|
try:
|
|
return tensor.to(device=device, dtype=torch.float16, non_blocking=True)
|
|
except torch.cuda.OutOfMemoryError as retry_exc:
|
|
if logger is not None:
|
|
logger.error(
|
|
f"[DeviceCheck] Tensor fallback to float16 on {device} still failed ({retry_exc}); reraising"
|
|
)
|
|
raise
|
|
raise
|
|
|
|
@staticmethod
|
|
def _infer_module_device(module: torch.nn.Module) -> Optional[torch.device]:
|
|
try:
|
|
parameter = next(module.parameters())
|
|
return parameter.device
|
|
except StopIteration:
|
|
pass
|
|
except Exception:
|
|
return None
|
|
try:
|
|
buffer = next(module.buffers())
|
|
return buffer.device
|
|
except StopIteration:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
@staticmethod
|
|
def _infer_module_dtype(module: torch.nn.Module) -> Optional[torch.dtype]:
|
|
try:
|
|
parameter = next(module.parameters())
|
|
return parameter.dtype
|
|
except StopIteration:
|
|
pass
|
|
except Exception:
|
|
return None
|
|
try:
|
|
buffer = next(module.buffers())
|
|
return buffer.dtype
|
|
except StopIteration:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
def _align_argument_devices(
|
|
self,
|
|
args: List[Any],
|
|
kwargs: Dict[str, Any],
|
|
) -> Tuple[List[Any], Dict[str, Any]]:
|
|
device = self._device
|
|
for index, value in enumerate(args):
|
|
args[index] = self._payload_to_device(value, device)
|
|
for key, value in list(kwargs.items()):
|
|
kwargs[key] = self._payload_to_device(value, device)
|
|
return args, kwargs
|
|
|
|
def forward(self, *args: Any, **kwargs: Any) -> Any:
|
|
self._move_target_to_device(self._inner, self._device, self._inner.__class__.__name__)
|
|
diffusion_model = getattr(self._inner, "diffusion_model", None)
|
|
self._move_target_to_device(diffusion_model, self._device, "diffusion_model")
|
|
args_list = list(args)
|
|
reference_tensor: Optional[torch.Tensor] = None
|
|
if args_list and isinstance(args_list[0], torch.Tensor):
|
|
reference_tensor = args_list[0]
|
|
elif isinstance(kwargs.get("x"), torch.Tensor):
|
|
reference_tensor = kwargs["x"]
|
|
args_aligned, kwargs_aligned = self._align_sigma_args(args_list, dict(kwargs), reference_tensor)
|
|
args_aligned, kwargs_aligned = self._align_argument_devices(args_aligned, kwargs_aligned)
|
|
return self._inner(*tuple(args_aligned), **kwargs_aligned)
|
|
|
|
def apply_model(self, *args: Any, **kwargs: Any) -> Any:
|
|
apply_fn = getattr(self._inner, "apply_model")
|
|
self._move_target_to_device(self._inner, self._device, self._inner.__class__.__name__)
|
|
diffusion_model = getattr(self._inner, "diffusion_model", None)
|
|
self._move_target_to_device(diffusion_model, self._device, "diffusion_model")
|
|
args_list = list(args)
|
|
reference_tensor: Optional[torch.Tensor] = None
|
|
if args_list and isinstance(args_list[0], torch.Tensor):
|
|
reference_tensor = args_list[0]
|
|
elif isinstance(kwargs.get("input_x"), torch.Tensor):
|
|
reference_tensor = kwargs["input_x"]
|
|
args_aligned, kwargs_aligned = self._align_sigma_args(args_list, dict(kwargs), reference_tensor)
|
|
args_aligned, kwargs_aligned = self._align_argument_devices(args_aligned, kwargs_aligned)
|
|
return apply_fn(*tuple(args_aligned), **kwargs_aligned)
|
|
|
|
def release(self) -> None:
|
|
for module in list(self._module_hook_targets):
|
|
handle = getattr(module, "_h4_device_alignment", None)
|
|
if handle is not None:
|
|
try:
|
|
handle.remove()
|
|
except Exception:
|
|
pass
|
|
for attr in (
|
|
"_h4_device_alignment",
|
|
"_h4_device_alignment_logged",
|
|
"_h4_device_alignment_device",
|
|
"_h4_device_alignment_label",
|
|
):
|
|
if hasattr(module, attr):
|
|
try:
|
|
delattr(module, attr)
|
|
except AttributeError:
|
|
setattr(module, attr, None)
|
|
for child in list(self._child_proxies.values()):
|
|
if isinstance(child, _DeviceAlignedModelProxy):
|
|
try:
|
|
child.release()
|
|
except Exception:
|
|
pass
|
|
self._module_hook_targets.clear()
|
|
self._module_proxies.clear()
|
|
self._child_proxies.clear()
|
|
self._delegated_attributes.clear()
|
|
try:
|
|
existing = _PROXY_REGISTRY.get(self._inner)
|
|
if existing is self:
|
|
del _PROXY_REGISTRY[self._inner]
|
|
except Exception:
|
|
pass
|
|
|
|
@classmethod
|
|
def _ensure_calc_patch(cls, logger: ToolkitLogger) -> None:
|
|
if cls._calc_patch_applied:
|
|
return
|
|
try:
|
|
import comfy.samplers as comfy_samplers # type: ignore
|
|
except Exception as exc: # pragma: no cover - defensive only
|
|
logger.warn(f"Unable to import comfy.samplers for timestep coercion: {exc}")
|
|
return
|
|
original = getattr(comfy_samplers, "calc_cond_batch", None)
|
|
if original is None or not callable(original):
|
|
logger.warn("calc_cond_batch missing; cannot install timestep coercion")
|
|
return
|
|
|
|
def patched_calc_cond_batch(model: Any, conds: Any, x_in: Any, timestep: Any, model_options: Any) -> Any:
|
|
coerced = cls._coerce_external_timestep(timestep, x_in, getattr(model, "device", None))
|
|
if coerced is not timestep:
|
|
logger.info(
|
|
f"[DeviceCheck] calc_cond_batch timestep realigned to {getattr(coerced, 'device', 'unknown')}"
|
|
)
|
|
return original(model, conds, x_in, coerced, model_options)
|
|
|
|
comfy_samplers.calc_cond_batch = patched_calc_cond_batch
|
|
cls._calc_cond_batch_original = original
|
|
cls._calc_patch_applied = True
|
|
logger.info("[DeviceCheck] calc_cond_batch timestep coercion installed")
|
|
|
|
@staticmethod
|
|
def _coerce_external_timestep(value: Any, reference: Any, fallback_device: Optional[Union[str, torch.device]]) -> Any:
|
|
target_device: Optional[torch.device] = None
|
|
target_dtype: torch.dtype = torch.float32
|
|
if isinstance(reference, torch.Tensor):
|
|
target_device = reference.device
|
|
target_dtype = reference.dtype
|
|
elif isinstance(fallback_device, torch.device):
|
|
target_device = fallback_device
|
|
elif isinstance(fallback_device, str):
|
|
try:
|
|
target_device = torch.device(fallback_device)
|
|
except Exception:
|
|
target_device = None
|
|
if target_device is None:
|
|
if isinstance(value, torch.Tensor):
|
|
target_device = value.device
|
|
target_dtype = value.dtype
|
|
else:
|
|
return value
|
|
if isinstance(value, torch.Tensor):
|
|
if value.device != target_device or value.dtype != target_dtype:
|
|
return value.to(device=target_device, dtype=target_dtype)
|
|
return value
|
|
if isinstance(value, (float, int)):
|
|
return torch.as_tensor(value, dtype=target_dtype, device=target_device)
|
|
if hasattr(value, "to"):
|
|
try:
|
|
coerced_any = value.to(device=target_device)
|
|
if isinstance(coerced_any, torch.Tensor):
|
|
if coerced_any.device != target_device or coerced_any.dtype != target_dtype:
|
|
coerced_any = coerced_any.to(device=target_device, dtype=target_dtype)
|
|
return coerced_any
|
|
except Exception:
|
|
return value
|
|
return value
|
|
|
|
|
|
class _DeviceAlignedDiffusionProxy:
|
|
# This class is now obsolete and will be removed.
|
|
pass
|
|
|
|
|
|
AXIS_PRESETS = (
|
|
"prompt",
|
|
"checkpoint",
|
|
"sampler",
|
|
"scheduler",
|
|
"cfg",
|
|
"steps",
|
|
"denoise",
|
|
"seed",
|
|
"lora",
|
|
"none",
|
|
)
|
|
|
|
|
|
PLOT_WIDGET_TOOLTIPS = {
|
|
"seed": "Base seed for the first run; axis overrides can provide per-cell values.",
|
|
"steps": "Default sampler steps; supply override lines on either axis to vary.",
|
|
"cfg": "Classifier-free guidance weight applied unless overridden.",
|
|
"denoise": "Amount of denoising to apply. 1.0 = full denoise, lower to blend latent inputs.",
|
|
"clip_skip": "How many CLIP layers to skip. Higher values can emphasise structure over detail.",
|
|
"sampler_name": "Sampler selection for baseline runs. Axis entries can swap samplers per cell.",
|
|
"scheduler_name": "Scheduler curve used to distribute steps.",
|
|
"width": "Pixel width for new latent creation when no input latent/image is supplied.",
|
|
"height": "Pixel height for new latent creation when no input latent/image is supplied.",
|
|
"checkpoint": "Starting checkpoint. Axis entries using the checkpoint preset can override per cell.",
|
|
"vae_name": "Override VAE to decode with. Leave on <checkpoint> to use the base bundle.",
|
|
"clip_text_name": "Override the text encoder CLIP. Uses checkpoint CLIP when left on <checkpoint>.",
|
|
"clip_vision_name": "Optional vision CLIP for dual models. Leave on <checkpoint> when unused.",
|
|
"positive_prompt": "Base positive prompt shared by all runs unless suffixed by axis instructions.",
|
|
"negative_prompt": "Negative prompt applied when CFG > 1.0.",
|
|
"x_axis_mode": "How to interpret each line listed in X axis values.",
|
|
"x_axis_values": "One entry per line. Accepts comma separated lists for convenience.",
|
|
"y_axis_mode": "How to interpret each line listed in Y axis values.",
|
|
"y_axis_values": "One entry per line. Accepts comma separated lists for convenience.",
|
|
}
|
|
|
|
|
|
|
|
AXIS_DRIVER_WIDGET_TOOLTIPS = {
|
|
"config": "JSON configuration for the Axis Driver UI. Connect the outputs to nodes expecting axis payloads.",
|
|
}
|
|
|
|
|
|
DEBUG_WIDGET_TOOLTIPS = {
|
|
"mode": "Monitor keeps signals internal. Passthrough relays inputs to outputs while logging.",
|
|
"go_ultra": "Ask the router to GO PLUS ULTRA?! and unlock a diagnostics panel packed with snapshots, previews, JSON logs, and anomaly detectors.",
|
|
}
|
|
|
|
CONSOLE_COLOR_SCHEMES: Dict[str, Dict[str, str]] = {
|
|
"dark": {
|
|
"background": "#0b0c10",
|
|
"panel": "#1f2833",
|
|
"text": "#c5c6c7",
|
|
"accent": "#66fcf1",
|
|
"muted": "#45a29e",
|
|
},
|
|
"light": {
|
|
"background": "#f7f7f7",
|
|
"panel": "#ffffff",
|
|
"text": "#1f2933",
|
|
"accent": "#2563eb",
|
|
"muted": "#4b5563",
|
|
},
|
|
"matrix": {
|
|
"background": "#050805",
|
|
"panel": "#0f2010",
|
|
"text": "#9aff9a",
|
|
"accent": "#39ff14",
|
|
"muted": "#2f7044",
|
|
},
|
|
"cyberpunk": {
|
|
"background": "#120022",
|
|
"panel": "#1e0140",
|
|
"text": "#f6f4ff",
|
|
"accent": "#f72585",
|
|
"muted": "#7209b7",
|
|
},
|
|
}
|
|
|
|
CONSOLE_VERBOSITY_LEVELS: Dict[str, int] = {
|
|
"minimal": 0,
|
|
"normal": 2,
|
|
"verbose": 8,
|
|
"trace": -1,
|
|
}
|
|
|
|
EXECUTION_LOGGER_WIDGET_TOOLTIPS: Dict[str, str] = {
|
|
"log_level": "Select the console severity used for emitted messages.",
|
|
"prefix": "Human-readable tag prepended to every log entry.",
|
|
"show_types": "Include Python type names for each connected payload.",
|
|
"show_shapes": "Append tensor shapes and conditioning lengths when available.",
|
|
}
|
|
|
|
SEED_BROADCASTER_WIDGET_TOOLTIPS: Dict[str, str] = {
|
|
"seed": "Base seed anchor. Used directly in fixed mode and as the starting point when you manually override other modes.",
|
|
"mode": "Choose how the generator produces seeds: hold the anchor, auto-increment, or roll a fresh random number.",
|
|
"increment_step": "Amount to add whenever increment mode advances. Ignored outside increment mode.",
|
|
"auto_advance": "When enabled the node advances itself every execution. Disable to hold the current seed until you press Randomize or edit the value manually.",
|
|
"random_digits": "How many digits to use when generating random seeds (1-12).",
|
|
}
|
|
|
|
VARIANATOR_PROFILE_RANGES: Dict[str, Tuple[float, float]] = {
|
|
"minimal": (0.30, 0.40),
|
|
"moderate": (0.40, 0.50),
|
|
"major": (0.50, 0.55),
|
|
}
|
|
|
|
VARIANATOR_WIDGET_TOOLTIPS: Dict[str, str] = {
|
|
"variation_count": "How many alternates to produce from the supplied latent. Each run clones the latent so upstream branches remain untouched.",
|
|
"variation_profile": "Controls the denoise band. Minimal keeps things tight, major allows bolder remixing.",
|
|
"seed_mode": "Fixed reuses the anchor seed, increment marches upward, random draws a deterministic sequence from the anchor.",
|
|
"base_seed": "Anchor seed. Acts as the starting point for increment mode or the RNG seed for random mode.",
|
|
"sampler_name": "Which sampler to use for every variation.",
|
|
"scheduler_name": "Noise schedule for sampler execution.",
|
|
"steps": "Sampler steps per variation. Keep modest; we are refining, not regenerating from scratch.",
|
|
"cfg": "Classifier-free guidance shared across the batch.",
|
|
"go_ultra": "Flip to capture JSON logs and optional preview artefacts for every variation.",
|
|
"ultra_json_log": "Persist a JSON dossier of the run when GO PLUS ULTRA?! is on.",
|
|
"ultra_cache_artifacts": "When GO PLUS ULTRA?! is on, save preview PNGs alongside the JSON log.",
|
|
"prompt_jitter_enabled": "Append weighted prompt fragments per variation. Requires a positive prompt and CLIP handle.",
|
|
"prompt_jitter_strength": "Maximum deviation when no explicit token range is provided (1.0 ± strength).",
|
|
"prompt_jitter_tokens": "Optional token list for jittering. One per line. Format: token|min|max (weights).",
|
|
"style_mix_enabled": "Blend a secondary conditioning or prompt into the main prompt using the mix ratio.",
|
|
"style_mix_ratio": "0 keeps the base prompt. 1.0 swaps fully to the style-mix source.",
|
|
"base_positive_prompt": "Fallback positive prompt when no conditioning input is connected (also used for prompt jitter).",
|
|
"base_negative_prompt": "Fallback negative prompt when no conditioning input is connected.",
|
|
"style_mix_prompt": "Optional prompt string to blend when style mixing is enabled and no conditioning input is provided.",
|
|
"style_mix_negative_prompt": "Optional negative prompt counterpart for style mixing.",
|
|
}
|
|
|
|
VARIANATOR_MAX_VARIATIONS = 16
|
|
VARIANATOR_SEED_LIMIT = 2**63 - 1
|
|
|
|
ULTRA_CONTROL_DEFINITIONS: Tuple[Dict[str, Any], ...] = (
|
|
{
|
|
"name": "ultra_capture_first_step",
|
|
"type": "BOOLEAN",
|
|
"default": True,
|
|
"label": "Snapshot: first step",
|
|
"tooltip": "Capture analytics for the earliest latent frame observed (or current latent when history is unavailable).",
|
|
},
|
|
{
|
|
"name": "ultra_capture_mid_step",
|
|
"type": "BOOLEAN",
|
|
"default": False,
|
|
"label": "Snapshot: midpoint",
|
|
"tooltip": "When latent history is provided, also analyse the midpoint frame for drift checks.",
|
|
},
|
|
{
|
|
"name": "ultra_capture_last_step",
|
|
"type": "BOOLEAN",
|
|
"default": True,
|
|
"label": "Snapshot: final step",
|
|
"tooltip": "Capture analytics for the most recent latent frame to spot end-of-run anomalies.",
|
|
},
|
|
{
|
|
"name": "ultra_preview_images",
|
|
"type": "BOOLEAN",
|
|
"default": True,
|
|
"label": "Generate preview image",
|
|
"tooltip": "Attempt to decode the analysed latent into a quick preview image when a VAE is connected.",
|
|
},
|
|
{
|
|
"name": "ultra_json_log",
|
|
"type": "BOOLEAN",
|
|
"default": True,
|
|
"label": "Write JSON session log",
|
|
"tooltip": "Serialise the captured diagnostics to a JSON file under ComfyUI's temp directory for later comparison.",
|
|
},
|
|
{
|
|
"name": "ultra_highlight_missing_conditioning",
|
|
"type": "BOOLEAN",
|
|
"default": True,
|
|
"label": "Highlight missing conditioning",
|
|
"tooltip": "Warn when positive/negative or auxiliary conditioning branches are absent to prevent silent misconfigurations.",
|
|
},
|
|
{
|
|
"name": "ultra_token_preview",
|
|
"type": "BOOLEAN",
|
|
"default": False,
|
|
"label": "Preview conditioning tokens",
|
|
"tooltip": "Attempt to echo prompt fragments and key embedding stats for the first few conditioning entries.",
|
|
},
|
|
{
|
|
"name": "ultra_latent_anomaly_checks",
|
|
"type": "BOOLEAN",
|
|
"default": True,
|
|
"label": "Check for latent anomalies",
|
|
"tooltip": "Scan tensors for NaN/Inf values and suspicious variance spikes to surface stability issues early.",
|
|
},
|
|
{
|
|
"name": "ultra_model_diff_tracking",
|
|
"type": "BOOLEAN",
|
|
"default": False,
|
|
"label": "Fingerprint attached models",
|
|
"tooltip": "Hash model and LoRA parameter signatures so you can verify which weights were active this run.",
|
|
},
|
|
{
|
|
"name": "ultra_watch_expression",
|
|
"type": "STRING",
|
|
"default": "",
|
|
"label": "Custom watch expression",
|
|
"tooltip": "Evaluate a Python expression against the inbound payloads (available globals: torch, np, slot names). Leave blank to disable.",
|
|
},
|
|
{
|
|
"name": "ultra_cache_artifacts",
|
|
"type": "BOOLEAN",
|
|
"default": True,
|
|
"label": "Persist preview artifacts",
|
|
"tooltip": "Keep generated preview images and JSON logs on disk instead of treating them as temporary diagnostics.",
|
|
},
|
|
)
|
|
|
|
ULTRA_CONTROL_DEFAULTS: Dict[str, Any] = {
|
|
control["name"]: control["default"] for control in ULTRA_CONTROL_DEFINITIONS
|
|
}
|
|
|
|
|
|
DEBUG_SLOT_DEFINITIONS: Tuple[Tuple[str, str, str], ...] = (
|
|
("model_in", "Model", "MODEL"),
|
|
("clip_in", "CLIP", "CLIP"),
|
|
("clip_vision_in", "CLIP_Vision", "CLIP_VISION"),
|
|
("vae_in", "VAE", "VAE"),
|
|
("conditioning_in", "Conditioning", "CONDITIONING"),
|
|
("conditioning_positive_in", "Positive", "CONDITIONING"),
|
|
("conditioning_negative_in", "Negative", "CONDITIONING"),
|
|
("latent_in", "Latent", "LATENT"),
|
|
("image_in", "Image", "IMAGE"),
|
|
("mask_in", "Mask", "MASK"),
|
|
)
|
|
|
|
DEBUG_SLOT_TOOLTIPS: Dict[str, str] = {
|
|
"model_in": "Connect any diffusion model blob to inspect or forward downstream.",
|
|
"clip_in": "Attach a CLIP text encoder payload for logging and pass-through.",
|
|
"clip_vision_in": "Optional CLIP vision encoder payload.",
|
|
"vae_in": "Supply a VAE instance for inspection or passthrough.",
|
|
"conditioning_in": "Generic conditioning list or bundle.",
|
|
"conditioning_positive_in": "Positive conditioning branch when separated upstream.",
|
|
"conditioning_negative_in": "Negative conditioning branch when separated upstream.",
|
|
"latent_in": "Latent dictionary (samples / noise etc.).",
|
|
"image_in": "Images or batches headed into the debugger.",
|
|
"mask_in": "Mask tensor payloads to monitor or route.",
|
|
}
|
|
|
|
ROUTER_BRANCH_SUFFIXES: Tuple[str, ...] = ("_a", "_b")
|
|
ROUTER_BRANCH_DISPLAY_NAMES: Dict[str, str] = {
|
|
"_a": "Branch A",
|
|
"_b": "Branch B",
|
|
}
|
|
|
|
ROUTER_SLOT_DEFINITIONS: Tuple[Tuple[str, str, str], ...] = tuple(
|
|
(
|
|
f"{slot_name}{suffix}",
|
|
f"{ROUTER_BRANCH_DISPLAY_NAMES[suffix]} · {display_name}",
|
|
slot_type,
|
|
)
|
|
for suffix in ROUTER_BRANCH_SUFFIXES
|
|
for slot_name, display_name, slot_type in DEBUG_SLOT_DEFINITIONS
|
|
)
|
|
|
|
ROUTER_SLOT_TOOLTIPS: Dict[str, str] = {}
|
|
for slot_name, display_name, _slot_type in DEBUG_SLOT_DEFINITIONS:
|
|
base_tooltip = DEBUG_SLOT_TOOLTIPS.get(slot_name)
|
|
for suffix in ROUTER_BRANCH_SUFFIXES:
|
|
branch_label = ROUTER_BRANCH_DISPLAY_NAMES[suffix]
|
|
tooltip = base_tooltip
|
|
if tooltip:
|
|
tooltip = f"{tooltip} ({branch_label})"
|
|
else:
|
|
tooltip = f"{branch_label}: {display_name} slot"
|
|
ROUTER_SLOT_TOOLTIPS[f"{slot_name}{suffix}"] = tooltip
|
|
|
|
ROUTER_VAE_MODEL_FALLBACKS: Dict[str, str] = {
|
|
f"vae_in{suffix}": f"model_in{suffix}"
|
|
for suffix in ROUTER_BRANCH_SUFFIXES
|
|
}
|
|
|
|
ROUTER_SLOT_RETURN_TYPES: Tuple[str, ...] = tuple(slot[2] for slot in ROUTER_SLOT_DEFINITIONS)
|
|
ROUTER_SLOT_RETURN_NAMES: Tuple[str, ...] = tuple(slot[1] for slot in ROUTER_SLOT_DEFINITIONS)
|
|
|
|
DEBUG_SLOT_RETURN_TYPES: Tuple[str, ...] = tuple(slot[2] for slot in DEBUG_SLOT_DEFINITIONS)
|
|
DEBUG_SLOT_RETURN_NAMES: Tuple[str, ...] = tuple(slot[1] for slot in DEBUG_SLOT_DEFINITIONS)
|
|
|
|
CONDITIONING_SLOT_LABELS: Dict[str, str] = {
|
|
"conditioning_in": "Generic conditioning",
|
|
"conditioning_positive_in": "Positive conditioning",
|
|
"conditioning_negative_in": "Negative conditioning",
|
|
}
|
|
|
|
|
|
class ToolkitLogger:
|
|
def __init__(self, namespace: str, *, enable_colour: bool = True) -> None:
|
|
self.namespace = namespace
|
|
self.enable_colour = enable_colour
|
|
|
|
def _emit(self, level: str, colour: str, message: str) -> None:
|
|
timestamp = time.strftime("%H:%M:%S")
|
|
prefix = f"[{timestamp}] [h4 Toolkit::{self.namespace}] {level:>5}"
|
|
if self.enable_colour:
|
|
print(f"{colour}{prefix} :: {message}{Style.RESET_ALL}")
|
|
else:
|
|
print(f"{prefix} :: {message}")
|
|
|
|
def trace(self, message: str) -> None:
|
|
if TRACE_ENABLED:
|
|
self._emit("TRACE", Style.DIM, message)
|
|
|
|
def debug(self, message: str) -> None:
|
|
self._emit("DEBUG", Style.NORMAL, message)
|
|
|
|
def info(self, message: str) -> None:
|
|
self._emit("INFO", Style.BRIGHT, message)
|
|
|
|
def warn(self, message: str) -> None:
|
|
self._emit("WARN", Fore.YELLOW, message)
|
|
|
|
def error(self, message: str) -> None:
|
|
self._emit("ERROR", Fore.RED, message)
|
|
|
|
|
|
GLOBAL_LOGGER = ToolkitLogger("h4_ToolKit")
|
|
|
|
|
|
@dataclass
|
|
class AxisDescriptor:
|
|
"""Represents a user-specified modifier from the X/Y axis panels."""
|
|
|
|
source_label: str
|
|
checkpoint: Optional[str] = None
|
|
prompt_suffix: str = ""
|
|
loras: List[Tuple[str, float]] = field(default_factory=list)
|
|
overrides: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
def describe(self) -> str:
|
|
pieces: List[str] = []
|
|
if self.checkpoint:
|
|
pieces.append(f"checkpoint={self.checkpoint}")
|
|
if self.prompt_suffix:
|
|
pieces.append(f"prompt+={self.prompt_suffix}")
|
|
for name, strength in self.loras:
|
|
pieces.append(f"lora={name}@{strength:.2f}")
|
|
for key, value in self.overrides.items():
|
|
pieces.append(f"{key}={value}")
|
|
return " | ".join(pieces) if pieces else "identity"
|
|
|
|
|
|
@dataclass
|
|
class GenerationPlan:
|
|
"""Concrete execution plan for a single plot cell."""
|
|
|
|
label: str
|
|
checkpoint_name: Optional[str]
|
|
prompt_suffix: str
|
|
loras: List[Tuple[str, float]] = field(default_factory=list)
|
|
overrides: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
def as_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"label": self.label,
|
|
"checkpoint": self.checkpoint_name,
|
|
"prompt_suffix": self.prompt_suffix,
|
|
"loras": [
|
|
{"name": name, "strength": strength} for name, strength in self.loras
|
|
],
|
|
"overrides": self.overrides,
|
|
}
|
|
|
|
|
|
def _axis_driver_default_state() -> Dict[str, Any]:
|
|
return copy.deepcopy(AXIS_DRIVER_DEFAULT_STATE)
|
|
|
|
|
|
def _axis_driver_normalise_style(raw: Any) -> Dict[str, Any]:
|
|
style = copy.deepcopy(AXIS_DRIVER_DEFAULT_STYLE)
|
|
if isinstance(raw, dict):
|
|
for key, value in raw.items():
|
|
if key in style:
|
|
style[key] = value
|
|
return style
|
|
|
|
|
|
def _axis_driver_normalise_item(preset: str, raw: Any) -> Dict[str, Any]:
|
|
payload = raw if isinstance(raw, dict) else {}
|
|
label = str(payload.get("label") or payload.get("source_label") or "").strip()
|
|
value = payload.get("value")
|
|
if value is None and "raw_value" in payload:
|
|
value = payload.get("raw_value")
|
|
overrides = payload.get("overrides")
|
|
overrides = overrides if isinstance(overrides, dict) else {}
|
|
strength_raw = payload.get("strength")
|
|
strength: Optional[float]
|
|
if preset == "lora":
|
|
try:
|
|
strength = float(strength_raw)
|
|
except (TypeError, ValueError):
|
|
strength = 0.75
|
|
else:
|
|
strength = None
|
|
return {
|
|
"label": label,
|
|
"value": value,
|
|
"strength": strength,
|
|
"overrides": copy.deepcopy(overrides),
|
|
}
|
|
|
|
|
|
def _axis_driver_normalise_axis(slot: str, raw: Any) -> Dict[str, Any]:
|
|
preset_default = AXIS_DRIVER_PRESET_DEFAULTS.get(slot, "none")
|
|
payload = raw if isinstance(raw, dict) else {}
|
|
preset_raw = str(payload.get("preset") or preset_default).lower()
|
|
preset = preset_raw if preset_raw in AXIS_DRIVER_SUPPORTED_PRESETS else preset_default
|
|
items_raw = payload.get("items") if isinstance(payload, dict) else None
|
|
items: List[Dict[str, Any]] = []
|
|
if isinstance(items_raw, list):
|
|
for entry in items_raw[:AXIS_DRIVER_MAX_ITEMS]:
|
|
items.append(_axis_driver_normalise_item(preset, entry))
|
|
return {"slot": slot, "preset": preset, "items": items}
|
|
|
|
|
|
def _axis_driver_normalise_state(raw: Any) -> Dict[str, Any]:
|
|
state = _axis_driver_default_state()
|
|
if not isinstance(raw, dict):
|
|
return state
|
|
|
|
state["style"] = _axis_driver_normalise_style(raw.get("style"))
|
|
slot_lookup = {axis["slot"]: axis for axis in state["axes"]}
|
|
axes_raw = raw.get("axes")
|
|
if isinstance(axes_raw, list):
|
|
for entry in axes_raw:
|
|
slot = str(entry.get("slot") if isinstance(entry, dict) else "").upper()
|
|
if slot in slot_lookup:
|
|
slot_lookup[slot].update(_axis_driver_normalise_axis(slot, entry))
|
|
# Ensure order and fallback defaults are preserved
|
|
ordered_axes: List[Dict[str, Any]] = []
|
|
for slot in AXIS_DRIVER_SLOT_ORDER:
|
|
base_axis = slot_lookup.get(slot, _axis_driver_normalise_axis(slot, {}))
|
|
if base_axis.get("preset") not in AXIS_DRIVER_SUPPORTED_PRESETS:
|
|
base_axis["preset"] = AXIS_DRIVER_PRESET_DEFAULTS.get(slot, "none")
|
|
if not isinstance(base_axis.get("items"), list):
|
|
base_axis["items"] = []
|
|
base_axis["items"] = base_axis["items"][:AXIS_DRIVER_MAX_ITEMS]
|
|
ordered_axes.append(base_axis)
|
|
state["axes"] = ordered_axes
|
|
return state
|
|
|
|
|
|
def _axis_driver_slot_payload(state: Dict[str, Any], slot: str) -> Dict[str, Any]:
|
|
axes = state.get("axes") if isinstance(state, dict) else None
|
|
axis: Optional[Dict[str, Any]] = None
|
|
if isinstance(axes, list):
|
|
for entry in axes:
|
|
if isinstance(entry, dict) and entry.get("slot") == slot:
|
|
axis = entry
|
|
break
|
|
if axis is None:
|
|
axis = _axis_driver_normalise_axis(slot, {})
|
|
payload_items: List[Dict[str, Any]] = []
|
|
for item in axis.get("items", []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
payload_items.append(
|
|
{
|
|
"label": str(item.get("label") or ""),
|
|
"value": item.get("value"),
|
|
"strength": item.get("strength"),
|
|
"overrides": copy.deepcopy(item.get("overrides") if isinstance(item.get("overrides"), dict) else {}),
|
|
}
|
|
)
|
|
return {
|
|
"slot": slot,
|
|
"preset": axis.get("preset", "none"),
|
|
"items": payload_items,
|
|
"style": copy.deepcopy(state.get("style", {})),
|
|
}
|
|
|
|
|
|
def _axis_driver_legacy_summary(state: Dict[str, Any]) -> str:
|
|
lines: List[str] = []
|
|
axes = state.get("axes") if isinstance(state, dict) else []
|
|
for axis in axes:
|
|
if not isinstance(axis, dict):
|
|
continue
|
|
slot = axis.get("slot", "?")
|
|
preset = axis.get("preset", "none")
|
|
header = f"Axis {slot} ({preset})"
|
|
items = axis.get("items") if isinstance(axis.get("items"), list) else []
|
|
if not items or preset == "none":
|
|
lines.append(f"{header}: <disabled>")
|
|
continue
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
label = item.get("label") or item.get("value") or ""
|
|
strength = item.get("strength")
|
|
if preset == "lora" and strength is not None:
|
|
lines.append(f"{header}: {label} @ {strength}")
|
|
else:
|
|
lines.append(f"{header}: {label}")
|
|
style = state.get("style") if isinstance(state, dict) else None
|
|
if isinstance(style, dict):
|
|
layout = style.get("label_layout", "overlay")
|
|
lines.append(f"Style: layout={layout}, font={style.get('font_family', 'default')} size={style.get('font_size', 22)}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _axis_driver_parse_config(config_text: str) -> Dict[str, Any]:
|
|
if not isinstance(config_text, str) or not config_text.strip():
|
|
return _axis_driver_default_state()
|
|
try:
|
|
raw = json.loads(config_text)
|
|
except Exception:
|
|
GLOBAL_LOGGER.warn("Axis Driver config parse failed; reverting to defaults")
|
|
return _axis_driver_default_state()
|
|
return _axis_driver_normalise_state(raw)
|
|
|
|
|
|
def _axis_driver_payload_to_descriptors(
|
|
payload: Optional[Dict[str, Any]],
|
|
) -> Tuple[List[AxisDescriptor], Optional[Dict[str, Any]]]:
|
|
if not isinstance(payload, dict):
|
|
return [], None
|
|
|
|
preset = str(payload.get("preset") or "none").lower()
|
|
items = payload.get("items") if isinstance(payload.get("items"), list) else []
|
|
style = payload.get("style") if isinstance(payload.get("style"), dict) else None
|
|
|
|
if preset not in AXIS_DRIVER_SUPPORTED_PRESETS or not items:
|
|
return [], style
|
|
|
|
descriptors: List[AxisDescriptor] = []
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
label = str(item.get("label") or item.get("value") or "").strip()
|
|
overrides_raw = item.get("overrides") if isinstance(item.get("overrides"), dict) else {}
|
|
overrides = copy.deepcopy(overrides_raw)
|
|
value = item.get("value")
|
|
|
|
if preset == "prompt":
|
|
text_value = "" if value is None else str(value)
|
|
descriptors.append(
|
|
AxisDescriptor(
|
|
source_label=label or text_value or "prompt",
|
|
prompt_suffix=text_value,
|
|
overrides=overrides,
|
|
)
|
|
)
|
|
elif preset == "checkpoint":
|
|
candidate = str(value or "")
|
|
resolved = resolve_checkpoint_name(candidate) or candidate
|
|
if not resolved:
|
|
continue
|
|
descriptors.append(
|
|
AxisDescriptor(
|
|
source_label=label or resolved,
|
|
checkpoint=resolved,
|
|
overrides=overrides,
|
|
)
|
|
)
|
|
elif preset == "lora":
|
|
candidate = str(value or "")
|
|
resolved = resolve_lora_name(candidate) or candidate
|
|
try:
|
|
strength = float(item.get("strength", 0.75))
|
|
except (TypeError, ValueError):
|
|
strength = 0.75
|
|
descriptors.append(
|
|
AxisDescriptor(
|
|
source_label=label or resolved,
|
|
loras=[(resolved, strength)],
|
|
overrides=overrides,
|
|
)
|
|
)
|
|
elif preset == "sampler":
|
|
if not value:
|
|
continue
|
|
sampler_name = str(value)
|
|
lookup = SAMPLER_LOOKUP.get(sampler_name.lower())
|
|
if lookup is None:
|
|
GLOBAL_LOGGER.warn(f"Axis Driver sampler '{sampler_name}' not recognised; skipping entry")
|
|
continue
|
|
overrides.setdefault("sampler", lookup)
|
|
descriptors.append(
|
|
AxisDescriptor(
|
|
source_label=label or sampler_name,
|
|
overrides=overrides,
|
|
)
|
|
)
|
|
elif preset == "scheduler":
|
|
if not value:
|
|
continue
|
|
scheduler_name = str(value)
|
|
lookup = SCHEDULER_LOOKUP.get(scheduler_name.lower())
|
|
if lookup is None:
|
|
GLOBAL_LOGGER.warn(f"Axis Driver scheduler '{scheduler_name}' not recognised; skipping entry")
|
|
continue
|
|
overrides.setdefault("scheduler", lookup)
|
|
descriptors.append(
|
|
AxisDescriptor(
|
|
source_label=label or scheduler_name,
|
|
overrides=overrides,
|
|
)
|
|
)
|
|
elif preset == "steps":
|
|
try:
|
|
steps_value = max(1, int(value))
|
|
except (TypeError, ValueError):
|
|
GLOBAL_LOGGER.warn(f"Axis Driver steps value '{value}' invalid; skipping entry")
|
|
continue
|
|
overrides.setdefault("steps", steps_value)
|
|
descriptors.append(
|
|
AxisDescriptor(
|
|
source_label=label or str(steps_value),
|
|
overrides=overrides,
|
|
)
|
|
)
|
|
elif preset == "cfg":
|
|
try:
|
|
cfg_value = float(value)
|
|
except (TypeError, ValueError):
|
|
GLOBAL_LOGGER.warn(f"Axis Driver CFG value '{value}' invalid; skipping entry")
|
|
continue
|
|
overrides.setdefault("cfg", round(cfg_value, 3))
|
|
descriptors.append(
|
|
AxisDescriptor(
|
|
source_label=label or str(cfg_value),
|
|
overrides=overrides,
|
|
)
|
|
)
|
|
elif preset == "denoise":
|
|
try:
|
|
denoise_value = float(value)
|
|
except (TypeError, ValueError):
|
|
GLOBAL_LOGGER.warn(f"Axis Driver denoise value '{value}' invalid; skipping entry")
|
|
continue
|
|
overrides.setdefault("denoise", max(0.0, min(1.0, round(denoise_value, 3))))
|
|
descriptors.append(
|
|
AxisDescriptor(
|
|
source_label=label or str(denoise_value),
|
|
overrides=overrides,
|
|
)
|
|
)
|
|
elif preset == "seed":
|
|
try:
|
|
seed_value = int(value)
|
|
except (TypeError, ValueError):
|
|
GLOBAL_LOGGER.warn(f"Axis Driver seed value '{value}' invalid; skipping entry")
|
|
continue
|
|
overrides.setdefault("seed", seed_value)
|
|
descriptors.append(
|
|
AxisDescriptor(
|
|
source_label=label or str(seed_value),
|
|
overrides=overrides,
|
|
)
|
|
)
|
|
|
|
return descriptors, style
|
|
|
|
|
|
class h4_AxisDriver:
|
|
"""Companion node that serialises structured axis presets for The Engine."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Any]: # noqa: N802
|
|
default_config = json.dumps(AXIS_DRIVER_DEFAULT_STATE, indent=2)
|
|
return {
|
|
"required": {
|
|
"config": (
|
|
"STRING",
|
|
{
|
|
"default": default_config,
|
|
"multiline": True,
|
|
"tooltip": AXIS_DRIVER_WIDGET_TOOLTIPS.get("config"),
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("axis_x", "axis_y", "axis_z", "legacy_summary")
|
|
FUNCTION = "emit"
|
|
CATEGORY = "h4 Toolkit/Generation"
|
|
|
|
def __init__(self) -> None:
|
|
self.logger = ToolkitLogger("h4_AxisDriver")
|
|
|
|
def emit(self, config: str) -> Tuple[str, str, str, str]:
|
|
state = _axis_driver_parse_config(config)
|
|
normalised = json.dumps(state, indent=2)
|
|
if normalised != config:
|
|
self.logger.trace("Axis Driver config normalised for downstream consumers")
|
|
slot_payloads = {
|
|
slot: json.dumps(_axis_driver_slot_payload(state, slot), indent=2)
|
|
for slot in AXIS_DRIVER_SLOT_ORDER
|
|
}
|
|
summary = _axis_driver_legacy_summary(state)
|
|
return (
|
|
slot_payloads.get("X", ""),
|
|
slot_payloads.get("Y", ""),
|
|
slot_payloads.get("Z", ""),
|
|
summary,
|
|
)
|
|
|
|
|
|
class h4_SeedBroadcaster:
|
|
"""Utility node that generates reproducible seeds with lightweight sequencing."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Any]: # noqa: N802
|
|
return {
|
|
"required": {
|
|
"seed": (
|
|
"INT",
|
|
{
|
|
"default": 123456789,
|
|
"min": 0,
|
|
"max": 2**63 - 1,
|
|
"tooltip": SEED_BROADCASTER_WIDGET_TOOLTIPS.get("seed"),
|
|
},
|
|
),
|
|
"mode": (
|
|
["fixed", "increment", "random"],
|
|
{
|
|
"default": "fixed",
|
|
"tooltip": SEED_BROADCASTER_WIDGET_TOOLTIPS.get("mode"),
|
|
},
|
|
),
|
|
"increment_step": (
|
|
"INT",
|
|
{
|
|
"default": 1,
|
|
"min": 1,
|
|
"max": 2**31 - 1,
|
|
"tooltip": SEED_BROADCASTER_WIDGET_TOOLTIPS.get("increment_step"),
|
|
},
|
|
),
|
|
"auto_advance": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": True,
|
|
"tooltip": SEED_BROADCASTER_WIDGET_TOOLTIPS.get("auto_advance"),
|
|
},
|
|
),
|
|
"random_digits": (
|
|
"INT",
|
|
{
|
|
"default": 10,
|
|
"min": 1,
|
|
"max": 12,
|
|
"tooltip": SEED_BROADCASTER_WIDGET_TOOLTIPS.get("random_digits"),
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("INT",)
|
|
RETURN_NAMES = ("seed",)
|
|
FUNCTION = "generate"
|
|
CATEGORY = "h4 Toolkit/Utility"
|
|
|
|
def __init__(self) -> None:
|
|
self.logger = ToolkitLogger("h4_SeedBroadcaster")
|
|
self._current_seed: Optional[int] = None
|
|
self._anchor_seed: Optional[int] = None
|
|
self._last_mode: Optional[str] = None
|
|
self._last_random_digits: int = 10
|
|
self._last_emitted_seed: Optional[int] = None
|
|
|
|
@staticmethod
|
|
def _coerce_seed(value: Optional[Any]) -> int:
|
|
try:
|
|
integer = int(value) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
integer = 0
|
|
limit = 2**63 - 1
|
|
return max(0, min(integer, limit))
|
|
|
|
@staticmethod
|
|
def _normalise_mode(mode: Optional[str]) -> str:
|
|
if not mode:
|
|
return "fixed"
|
|
candidate = str(mode).strip().lower()
|
|
return candidate if candidate in {"fixed", "increment", "random"} else "fixed"
|
|
|
|
@staticmethod
|
|
def _normalise_step(value: Optional[Any]) -> int:
|
|
try:
|
|
step = int(value) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
step = 1
|
|
return max(1, min(step, 2**31 - 1))
|
|
|
|
@staticmethod
|
|
def _normalise_digits(value: Optional[Any]) -> int:
|
|
try:
|
|
digits = int(value) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
digits = 10
|
|
return max(1, min(digits, 12))
|
|
|
|
def _generate_random_seed(self, digits: int) -> int:
|
|
digits = self._normalise_digits(digits)
|
|
upper_bound = min(10**digits - 1, 2**63 - 1)
|
|
lower_bound = 0 if digits == 1 else 10 ** (digits - 1)
|
|
span = upper_bound - lower_bound + 1
|
|
return lower_bound + secrets.randbelow(span)
|
|
|
|
def generate(
|
|
self,
|
|
seed: int,
|
|
mode: str,
|
|
increment_step: int,
|
|
auto_advance: bool,
|
|
random_digits: int,
|
|
) -> Tuple[int]:
|
|
base_seed = self._coerce_seed(seed)
|
|
mode_normalised = self._normalise_mode(mode)
|
|
step = self._normalise_step(increment_step)
|
|
digits = self._normalise_digits(random_digits)
|
|
auto_flag = bool(auto_advance)
|
|
|
|
changed_mode = mode_normalised != self._last_mode
|
|
base_changed = self._anchor_seed is None or base_seed != self._anchor_seed
|
|
digits_changed = mode_normalised == "random" and digits != self._last_random_digits
|
|
|
|
need_reset = (
|
|
self._current_seed is None
|
|
or changed_mode
|
|
or digits_changed
|
|
or (base_changed and mode_normalised != "random")
|
|
)
|
|
manual_override = (
|
|
mode_normalised == "random"
|
|
and base_changed
|
|
and not changed_mode
|
|
and not digits_changed
|
|
)
|
|
|
|
if need_reset:
|
|
if mode_normalised == "random" and not base_changed:
|
|
self._current_seed = self._generate_random_seed(digits)
|
|
else:
|
|
self._current_seed = base_seed
|
|
if mode_normalised == "random" and base_changed:
|
|
self.logger.info(
|
|
f"[Seed Generator] Manual override detected; adopting seed {self._current_seed}"
|
|
)
|
|
elif manual_override:
|
|
self._current_seed = base_seed
|
|
|
|
result = int(self._current_seed or 0)
|
|
|
|
if auto_flag:
|
|
if mode_normalised == "increment":
|
|
next_seed = self._coerce_seed(result + step)
|
|
self._current_seed = next_seed
|
|
elif mode_normalised == "random":
|
|
self._current_seed = self._generate_random_seed(digits)
|
|
else:
|
|
self._current_seed = base_seed
|
|
else:
|
|
self._current_seed = result
|
|
|
|
self._anchor_seed = base_seed
|
|
self._last_mode = mode_normalised
|
|
self._last_random_digits = digits
|
|
|
|
if self._last_emitted_seed != result:
|
|
self.logger.info(
|
|
f"[Seed Generator] mode={mode_normalised} auto={'on' if auto_flag else 'off'} emitted seed {result}"
|
|
)
|
|
else:
|
|
self.logger.trace(
|
|
f"[Seed Generator] mode={mode_normalised} reusing seed {result}"
|
|
)
|
|
self._last_emitted_seed = result
|
|
return (result,)
|
|
|
|
|
|
def _split_lines(value: str) -> List[str]:
|
|
return [line.strip() for line in value.splitlines() if line.strip()]
|
|
|
|
|
|
def _normalise_token(token: str) -> str:
|
|
return token.replace("\\", "/").lower().strip()
|
|
|
|
|
|
def _resolve_asset_path(category: str, identifier: str) -> Optional[str]:
|
|
if not identifier:
|
|
return None
|
|
candidate = identifier.strip()
|
|
if os.path.isabs(candidate):
|
|
return candidate if os.path.exists(candidate) else None
|
|
try:
|
|
return folder_paths.get_full_path(category, candidate)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _normalise_model_family_tag(tag: Optional[str]) -> Optional[str]:
|
|
if tag is None:
|
|
return None
|
|
lowered = str(tag).strip().lower().replace(" ", "")
|
|
if not lowered:
|
|
return None
|
|
normalisers = {
|
|
"stable-diffusionxl": "sdxl",
|
|
"stablediffusionxl": "sdxl",
|
|
"sdxl": "sdxl",
|
|
"xl": "sdxl",
|
|
"stable-diffusion1.5": "sd15",
|
|
"stablediffusion1.5": "sd15",
|
|
"sd1.5": "sd15",
|
|
"sd15": "sd15",
|
|
"1.5": "sd15",
|
|
"v1-5": "sd15",
|
|
}
|
|
for needle, family in normalisers.items():
|
|
if needle in lowered:
|
|
return family
|
|
segments = [segment for segment in re.split(r"[^a-z0-9]+", lowered) if segment]
|
|
if any(segment in {"sdxl", "xl"} for segment in segments):
|
|
return "sdxl"
|
|
if any(segment in {"sd15", "15"} for segment in segments):
|
|
return "sd15"
|
|
return None
|
|
|
|
|
|
def _infer_family_from_name(path_like: Optional[str]) -> Optional[str]:
|
|
if not path_like:
|
|
return None
|
|
candidate = str(path_like).replace("\\", "/").lower()
|
|
return _normalise_model_family_tag(candidate)
|
|
|
|
|
|
def _family_from_file(path: Optional[str], logger: Optional[Any] = None, asset_label: str = "asset") -> Optional[str]:
|
|
if not path:
|
|
return None
|
|
metadata_family: Optional[str] = None
|
|
try:
|
|
if str(path).lower().endswith(".safetensors"):
|
|
from safetensors import safe_open
|
|
|
|
with safe_open(path, framework="pt", device="cpu") as handle:
|
|
meta = handle.metadata() or {}
|
|
for key in ("ss_base_model_version", "base_model", "format"):
|
|
family = _normalise_model_family_tag(meta.get(key))
|
|
if family:
|
|
metadata_family = family
|
|
break
|
|
except Exception as exc: # pragma: no cover - depends on external files
|
|
if logger is not None:
|
|
logger.warn(f"[LoRA Inspector] Could not read metadata for {asset_label} '{path}': {exc}")
|
|
return metadata_family or _infer_family_from_name(path)
|
|
|
|
|
|
def _infer_family_from_model_object(model_obj: Any) -> Optional[str]:
|
|
candidates = [model_obj, getattr(model_obj, "model", None), getattr(model_obj, "wrapped_model", None)]
|
|
seen: set[int] = set()
|
|
for candidate in candidates:
|
|
if candidate is None:
|
|
continue
|
|
ident = id(candidate)
|
|
if ident in seen:
|
|
continue
|
|
seen.add(ident)
|
|
for attr_name in ("model_type", "base_model", "architecture", "name", "type"):
|
|
value = getattr(candidate, attr_name, None)
|
|
family = _normalise_model_family_tag(value)
|
|
if family:
|
|
return family
|
|
config = getattr(candidate, "model_config", None)
|
|
config_items: Dict[str, Any] = {}
|
|
if isinstance(config, dict):
|
|
config_items = config
|
|
elif hasattr(config, "__dict__"):
|
|
config_items = dict(vars(config))
|
|
elif hasattr(config, "_asdict") and callable(getattr(config, "_asdict")):
|
|
try:
|
|
config_items = dict(config._asdict()) # type: ignore[misc]
|
|
except Exception: # pragma: no cover - fallback best effort
|
|
config_items = {}
|
|
if config_items:
|
|
for key in ("model_type", "base_model", "architecture", "name", "type"):
|
|
family = _normalise_model_family_tag(config_items.get(key))
|
|
if family:
|
|
return family
|
|
return None
|
|
|
|
|
|
def resolve_checkpoint_name(token: str) -> Optional[str]:
|
|
candidates_raw = folder_paths.get_filename_list("checkpoints")
|
|
candidates = list(candidates_raw or [])
|
|
if not token:
|
|
return None
|
|
if os.path.isabs(token) and os.path.exists(token):
|
|
return token
|
|
token_norm = _normalise_token(token)
|
|
direct_map = {_normalise_token(item): item for item in candidates}
|
|
if token_norm in direct_map:
|
|
return direct_map[token_norm]
|
|
stripped = token_norm
|
|
for suffix in (".safetensors", ".ckpt", ".pt"):
|
|
if stripped.endswith(suffix):
|
|
stripped = stripped[: -len(suffix)]
|
|
break
|
|
best_match: Optional[str] = None
|
|
for item in candidates:
|
|
normalised = _normalise_token(item)
|
|
name_without_ext = normalised.rsplit(".", 1)[0]
|
|
if normalised.endswith(token_norm) or name_without_ext.endswith(stripped):
|
|
best_match = item
|
|
break
|
|
return best_match
|
|
|
|
|
|
def resolve_lora_name(token: str) -> Optional[str]:
|
|
candidates_raw = folder_paths.get_filename_list("loras")
|
|
candidates = list(candidates_raw or [])
|
|
if not token:
|
|
return None
|
|
if os.path.isabs(token) and os.path.exists(token):
|
|
return token
|
|
token_norm = _normalise_token(token)
|
|
direct_map = {_normalise_token(item): item for item in candidates}
|
|
if token_norm in direct_map:
|
|
return direct_map[token_norm]
|
|
stripped = token_norm.rsplit("/", 1)[-1]
|
|
for item in candidates:
|
|
if _normalise_token(item).endswith(stripped):
|
|
return item
|
|
return None
|
|
|
|
|
|
def _callable_or_value(candidate: Any) -> Any:
|
|
torch_nn = getattr(torch, "nn", None)
|
|
module_cls = getattr(torch_nn, "Module", None) if torch_nn is not None else None
|
|
if module_cls is not None and isinstance(candidate, module_cls):
|
|
return candidate
|
|
if callable(candidate):
|
|
call_signature: Optional[inspect.Signature]
|
|
try:
|
|
call_signature = inspect.signature(candidate) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
call_signature = None
|
|
if call_signature is not None:
|
|
requires_arguments = any(
|
|
param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD)
|
|
and param.default is inspect._empty
|
|
for param in call_signature.parameters.values()
|
|
)
|
|
has_var_args = any(
|
|
param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD)
|
|
for param in call_signature.parameters.values()
|
|
)
|
|
if requires_arguments:
|
|
return candidate
|
|
if has_var_args:
|
|
return candidate
|
|
try:
|
|
return candidate() # type: ignore[misc]
|
|
except TypeError:
|
|
return None
|
|
return candidate
|
|
|
|
|
|
def _extract_model_sampling(model: Any) -> Optional[Any]:
|
|
if model is None:
|
|
return None
|
|
|
|
attribute_chains = (
|
|
("model_sampling",),
|
|
("model", "model_sampling"),
|
|
("model", "model", "model_sampling"),
|
|
("model", "inner_model", "model_sampling"),
|
|
("inner_model", "model_sampling"),
|
|
("clip", "model_sampling"),
|
|
("latent_format", "model_sampling"),
|
|
("model", "latent_format", "model_sampling"),
|
|
("model", "model", "latent_format", "model_sampling"),
|
|
("diffusion_model", "model_sampling"),
|
|
("diffusion_model", "latent_format", "model_sampling"),
|
|
("inner_model", "latent_format", "model_sampling"),
|
|
)
|
|
|
|
for chain in attribute_chains:
|
|
current = model
|
|
for attr in chain:
|
|
if current is None:
|
|
break
|
|
current = getattr(current, attr, None)
|
|
else:
|
|
value = _callable_or_value(current)
|
|
if value is not None:
|
|
return value
|
|
|
|
for helper_name in ("get_model_sampling", "model_sampling_func"):
|
|
helper = getattr(model, helper_name, None)
|
|
value = _callable_or_value(helper)
|
|
if value is not None:
|
|
return value
|
|
|
|
fallback = getattr(model, "model_sampling", None)
|
|
return _callable_or_value(fallback)
|
|
|
|
|
|
def _unwrap_sampler_candidate(candidate: Any) -> Optional[Any]:
|
|
if candidate is None:
|
|
return None
|
|
if hasattr(candidate, "sample") and callable(getattr(candidate, "sample")):
|
|
return candidate
|
|
if isinstance(candidate, (list, tuple, set)):
|
|
for item in candidate:
|
|
resolved = _unwrap_sampler_candidate(item)
|
|
if resolved is not None:
|
|
return resolved
|
|
return None
|
|
if isinstance(candidate, dict):
|
|
for key in ("sampler", "sampler_obj", "sampler_object", "sampler_instance"):
|
|
if key in candidate:
|
|
resolved = _unwrap_sampler_candidate(candidate[key])
|
|
if resolved is not None:
|
|
return resolved
|
|
return None
|
|
return None
|
|
|
|
|
|
def _call_with_available_arguments(target: Any, available: Dict[str, Any]) -> Optional[Any]:
|
|
if not callable(target):
|
|
return None
|
|
try:
|
|
signature = inspect.signature(target) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
signature = None
|
|
|
|
if signature is None:
|
|
try:
|
|
return target() # type: ignore[misc]
|
|
except TypeError:
|
|
return None
|
|
|
|
args: List[Any] = []
|
|
kwargs: Dict[str, Any] = {}
|
|
for name, param in signature.parameters.items():
|
|
if param.kind == param.VAR_POSITIONAL:
|
|
continue
|
|
if param.kind == param.VAR_KEYWORD:
|
|
kwargs.update({key: value for key, value in available.items() if value is not None})
|
|
continue
|
|
value = available.get(name)
|
|
if value is None:
|
|
if param.default is inspect._empty:
|
|
return None
|
|
continue
|
|
if param.kind == param.POSITIONAL_ONLY:
|
|
args.append(value)
|
|
else:
|
|
kwargs[name] = value
|
|
|
|
try:
|
|
return target(*args, **kwargs) # type: ignore[misc]
|
|
except TypeError:
|
|
return None
|
|
|
|
|
|
def _resolve_sampler_object(
|
|
logger: ToolkitLogger,
|
|
sampler_name: str,
|
|
scheduler_name: str,
|
|
model: Any,
|
|
*,
|
|
device: Optional[torch.device] = None,
|
|
model_sampling: Optional[Any] = None,
|
|
|
|
model_sampling_meta: Optional[Dict[str, Any]] = None,
|
|
) -> Optional[Any]:
|
|
resolved_available: Dict[str, Any] = {
|
|
"sampler_name": sampler_name,
|
|
"sampler": sampler_name,
|
|
"name": sampler_name,
|
|
"scheduler_name": scheduler_name,
|
|
"scheduler": scheduler_name,
|
|
"schedule_name": scheduler_name,
|
|
"model": model,
|
|
"model_sampling": model_sampling,
|
|
"model_type": (model_sampling_meta or {}).get("model_type"),
|
|
"sigma_min": (model_sampling_meta or {}).get("sigma_min"),
|
|
"sigma_max": (model_sampling_meta or {}).get("sigma_max"),
|
|
"sigma_data": (model_sampling_meta or {}).get("sigma_data"),
|
|
"sigma_schedule": (model_sampling_meta or {}).get("sigma_schedule"),
|
|
"schedule": (model_sampling_meta or {}).get("schedule"),
|
|
"device": device,
|
|
}
|
|
resolved_available = {key: value for key, value in resolved_available.items() if value is not None}
|
|
|
|
def attempt_target(target: Any) -> Optional[Any]:
|
|
candidate = _call_with_available_arguments(target, resolved_available)
|
|
sampler_object = _unwrap_sampler_candidate(candidate)
|
|
return sampler_object
|
|
|
|
# Direct module-level factory functions
|
|
factory_candidates: List[Any] = []
|
|
preferred_names = {
|
|
"create_sampler",
|
|
"build_sampler",
|
|
"make_sampler",
|
|
"sampler_from_config",
|
|
}
|
|
for name in preferred_names:
|
|
attr = getattr(comfy_samplers, name, None)
|
|
if callable(attr):
|
|
factory_candidates.append(attr)
|
|
|
|
# Objects providing factory methods
|
|
for attr_name in dir(comfy_samplers):
|
|
lower_name = attr_name.lower()
|
|
if "sampler" not in lower_name:
|
|
continue
|
|
if lower_name in {"sample", "sample_inner", "sampler"}:
|
|
continue
|
|
attr = getattr(comfy_samplers, attr_name)
|
|
if callable(attr):
|
|
|
|
factory_candidates.append(attr)
|
|
continue
|
|
for method_name in ("create_sampler", "build_sampler", "make_sampler"):
|
|
method = getattr(attr, method_name, None)
|
|
if callable(method):
|
|
factory_candidates.append(method)
|
|
|
|
seen_factories: List[Any] = []
|
|
for factory in factory_candidates:
|
|
if factory in seen_factories:
|
|
continue
|
|
seen_factories.append(factory)
|
|
try:
|
|
sampler_object = attempt_target(factory)
|
|
except Exception as exc: # pragma: no cover - environment specific
|
|
logger.trace(
|
|
f"Sampler factory '{getattr(factory, '__name__', repr(factory))}' failed: {exc}"
|
|
)
|
|
continue
|
|
if sampler_object is not None:
|
|
return sampler_object
|
|
|
|
# Model-provided helpers
|
|
for method_name in (
|
|
"create_sampler",
|
|
"make_sampler",
|
|
"build_sampler",
|
|
"get_sampler",
|
|
"sampler",
|
|
"sampler_factory",
|
|
):
|
|
method = getattr(model, method_name, None)
|
|
if method is None or not callable(method):
|
|
continue
|
|
try:
|
|
sampler_object = attempt_target(method)
|
|
except Exception as exc: # pragma: no cover - environment specific
|
|
logger.trace(
|
|
f"Model sampler helper '{method_name}' failed: {exc}"
|
|
)
|
|
continue
|
|
if sampler_object is not None:
|
|
return sampler_object
|
|
|
|
return None
|
|
|
|
|
|
def parse_axis_entries(mode: str, raw_text: str) -> List[AxisDescriptor]:
|
|
"""Parses axis definitions into typed descriptors according to the selected preset."""
|
|
|
|
entries: List[AxisDescriptor] = []
|
|
lines = _split_lines(raw_text)
|
|
if lines:
|
|
GLOBAL_LOGGER.trace(
|
|
f"Parsing axis entries for mode={mode} ({len(lines)} entries)"
|
|
)
|
|
|
|
def _append_descriptor(descriptor: AxisDescriptor) -> None:
|
|
entries.append(descriptor)
|
|
GLOBAL_LOGGER.trace(f"Axis parsed -> {descriptor.describe()}")
|
|
|
|
if not lines:
|
|
return entries
|
|
|
|
tokenizing_modes = {
|
|
"checkpoint",
|
|
"sampler",
|
|
"scheduler",
|
|
"cfg",
|
|
"steps",
|
|
"denoise",
|
|
"seed",
|
|
"lora",
|
|
}
|
|
|
|
for raw_line in lines:
|
|
segments = [raw_line]
|
|
if mode in tokenizing_modes:
|
|
exploded = [segment.strip() for segment in raw_line.split(",") if segment.strip()]
|
|
if exploded:
|
|
segments = exploded
|
|
|
|
for entry in segments:
|
|
lowered = entry.lower()
|
|
if lowered in ("none", "base", "default", "-"):
|
|
_append_descriptor(AxisDescriptor(source_label=entry))
|
|
continue
|
|
|
|
if mode == "prompt":
|
|
_append_descriptor(AxisDescriptor(source_label=entry, prompt_suffix=entry))
|
|
continue
|
|
|
|
if mode == "checkpoint":
|
|
token = entry
|
|
if lowered.startswith("checkpoint:"):
|
|
token = entry.split(":", 1)[1].strip()
|
|
resolved = resolve_checkpoint_name(token)
|
|
if resolved:
|
|
_append_descriptor(AxisDescriptor(source_label=entry, checkpoint=resolved))
|
|
else:
|
|
GLOBAL_LOGGER.warn(f"Checkpoint '{entry}' not found; skipping")
|
|
continue
|
|
|
|
if mode == "cfg":
|
|
try:
|
|
value = round(float(entry), 3)
|
|
_append_descriptor(
|
|
AxisDescriptor(
|
|
source_label=entry,
|
|
overrides={"cfg": value},
|
|
)
|
|
)
|
|
except ValueError:
|
|
GLOBAL_LOGGER.warn(f"Invalid CFG value '{entry}'")
|
|
continue
|
|
|
|
if mode == "steps":
|
|
try:
|
|
value = max(1, int(float(entry)))
|
|
_append_descriptor(
|
|
AxisDescriptor(
|
|
source_label=entry,
|
|
overrides={"steps": value},
|
|
)
|
|
)
|
|
except ValueError:
|
|
GLOBAL_LOGGER.warn(f"Invalid steps value '{entry}'")
|
|
continue
|
|
|
|
if mode == "sampler":
|
|
candidate = entry.strip()
|
|
lookup = SAMPLER_LOOKUP.get(candidate.lower())
|
|
if lookup:
|
|
_append_descriptor(AxisDescriptor(source_label=entry, overrides={"sampler": lookup}))
|
|
else:
|
|
GLOBAL_LOGGER.warn(f"Sampler '{entry}' is not recognised; skipping")
|
|
continue
|
|
|
|
if mode == "scheduler":
|
|
candidate = entry.strip()
|
|
lookup = SCHEDULER_LOOKUP.get(candidate.lower())
|
|
if lookup:
|
|
_append_descriptor(
|
|
AxisDescriptor(source_label=entry, overrides={"scheduler": lookup})
|
|
)
|
|
else:
|
|
GLOBAL_LOGGER.warn(f"Scheduler '{entry}' is not recognised; skipping")
|
|
continue
|
|
|
|
if mode == "denoise":
|
|
try:
|
|
value = min(1.0, max(0.0, float(entry)))
|
|
_append_descriptor(
|
|
AxisDescriptor(source_label=entry, overrides={"denoise": round(value, 3)})
|
|
)
|
|
except ValueError:
|
|
GLOBAL_LOGGER.warn(f"Invalid denoise value '{entry}'")
|
|
continue
|
|
|
|
if mode == "seed":
|
|
try:
|
|
value = int(float(entry))
|
|
_append_descriptor(
|
|
AxisDescriptor(source_label=entry, overrides={"seed": value})
|
|
)
|
|
except ValueError:
|
|
GLOBAL_LOGGER.warn(f"Invalid seed '{entry}'")
|
|
continue
|
|
|
|
if mode == "lora":
|
|
payload = entry
|
|
if payload.lower().startswith("lora:"):
|
|
payload = payload.split(":", 1)[1]
|
|
strength = 1.0
|
|
name_part = payload
|
|
if "@" in payload:
|
|
name_part, strength_part = payload.split("@", 1)
|
|
try:
|
|
strength = float(strength_part)
|
|
except ValueError:
|
|
strength = 1.0
|
|
elif "|" in payload:
|
|
name_part, strength_part = payload.split("|", 1)
|
|
try:
|
|
strength = float(strength_part)
|
|
except ValueError:
|
|
strength = 1.0
|
|
resolved = resolve_lora_name(name_part.strip())
|
|
if resolved:
|
|
_append_descriptor(
|
|
AxisDescriptor(
|
|
source_label=entry,
|
|
loras=[(resolved, strength)],
|
|
)
|
|
)
|
|
else:
|
|
GLOBAL_LOGGER.warn(f"LoRA '{entry}' not found; skipping")
|
|
continue
|
|
|
|
if mode == "none":
|
|
_append_descriptor(AxisDescriptor(source_label=entry))
|
|
continue
|
|
|
|
# Fallback to prompt behaviour when mode is unknown
|
|
_append_descriptor(AxisDescriptor(source_label=entry, prompt_suffix=entry))
|
|
|
|
return entries
|
|
|
|
|
|
def build_generation_matrix(
|
|
base_checkpoint: Optional[str],
|
|
x_descriptors: List[AxisDescriptor],
|
|
y_descriptors: List[AxisDescriptor],
|
|
) -> List[GenerationPlan]:
|
|
"""Computes the cartesian product of axis descriptors into concrete plans."""
|
|
|
|
def expand(axis_desc: List[AxisDescriptor]) -> List[List[AxisDescriptor]]:
|
|
return [[desc] for desc in axis_desc] or [[AxisDescriptor(source_label="∅")]]
|
|
|
|
runs: List[GenerationPlan] = []
|
|
for y_bundle in expand(y_descriptors):
|
|
for x_bundle in expand(x_descriptors):
|
|
combined = x_bundle + y_bundle
|
|
checkpoint_name = base_checkpoint
|
|
suffixes: List[str] = []
|
|
loras: List[Tuple[str, float]] = []
|
|
label_parts: List[str] = []
|
|
overrides: Dict[str, Any] = {}
|
|
for descriptor in combined:
|
|
label_parts.append(descriptor.describe())
|
|
if descriptor.checkpoint:
|
|
checkpoint_name = descriptor.checkpoint
|
|
if descriptor.prompt_suffix:
|
|
suffixes.append(descriptor.prompt_suffix)
|
|
if descriptor.loras:
|
|
loras.extend(descriptor.loras)
|
|
if descriptor.overrides:
|
|
overrides.update(descriptor.overrides)
|
|
label = " | ".join([part for part in label_parts if part != "identity"]) or "base"
|
|
plan = GenerationPlan(
|
|
label=label,
|
|
checkpoint_name=checkpoint_name,
|
|
prompt_suffix="\n".join(suffixes) if suffixes else "",
|
|
loras=loras,
|
|
overrides=overrides,
|
|
)
|
|
runs.append(plan)
|
|
GLOBAL_LOGGER.trace(
|
|
f"Planned run: checkpoint={plan.checkpoint_name}, prompt+={plan.prompt_suffix!r}, loras={plan.loras}, overrides={plan.overrides}"
|
|
)
|
|
return runs
|
|
|
|
|
|
def auto_square_layout(total_images: int) -> Tuple[int, int]:
|
|
"""Find a grid layout that is as square as possible."""
|
|
|
|
if total_images <= 0:
|
|
return (1, 1)
|
|
root = int(math.sqrt(total_images))
|
|
for columns in range(root, 0, -1):
|
|
if total_images % columns == 0:
|
|
rows = total_images // columns
|
|
return (max(1, rows), max(1, columns))
|
|
columns = max(1, root)
|
|
rows = math.ceil(total_images / columns)
|
|
return (rows, columns)
|
|
|
|
|
|
def compose_image_grid(
|
|
images: List[torch.Tensor],
|
|
rows_hint: Optional[int] = None,
|
|
cols_hint: Optional[int] = None,
|
|
) -> torch.Tensor:
|
|
"""Stack a list of decoded image tensors into a single grid image."""
|
|
|
|
if not images:
|
|
raise ValueError("compose_image_grid called with no images")
|
|
def to_bhwc(tensor: torch.Tensor) -> torch.Tensor:
|
|
if tensor.ndim == 4:
|
|
# Attempt to detect BCHW vs BHWC based on channel dimension size.
|
|
if tensor.shape[-1] <= 4:
|
|
return tensor
|
|
return tensor.permute(0, 2, 3, 1)
|
|
if tensor.ndim == 3:
|
|
if tensor.shape[-1] <= 4:
|
|
return tensor.unsqueeze(0)
|
|
if tensor.shape[0] <= 4:
|
|
return tensor.permute(1, 2, 0).unsqueeze(0)
|
|
raise ValueError(f"Unsupported tensor shape for grid composition: {tuple(tensor.shape)}")
|
|
|
|
batched: List[torch.Tensor] = []
|
|
for tensor in images:
|
|
normalised = to_bhwc(tensor)
|
|
for item in normalised:
|
|
batched.append(item)
|
|
|
|
total = len(batched)
|
|
if rows_hint is not None and cols_hint is not None:
|
|
rows, cols = rows_hint, cols_hint
|
|
else:
|
|
rows, cols = auto_square_layout(total)
|
|
GLOBAL_LOGGER.trace(
|
|
f"Composing grid with {total} tiles -> layout {rows}x{cols}"
|
|
)
|
|
sample_shape = batched[0].shape
|
|
height, width, channels = sample_shape[0], sample_shape[1], sample_shape[2]
|
|
grid = torch.zeros(
|
|
(rows * height, cols * width, channels),
|
|
dtype=batched[0].dtype,
|
|
device=batched[0].device,
|
|
)
|
|
for idx, tile in enumerate(batched):
|
|
if tile.shape != sample_shape:
|
|
raise ValueError(
|
|
f"Grid tile shape mismatch: expected {sample_shape}, received {tuple(tile.shape)}"
|
|
)
|
|
r = idx // cols
|
|
c = idx % cols
|
|
grid[
|
|
r * height : (r + 1) * height,
|
|
c * width : (c + 1) * width,
|
|
:,
|
|
] = tile
|
|
return grid.unsqueeze(0)
|
|
|
|
|
|
def _parse_hex_colour(value: Optional[str], default: Tuple[int, int, int]) -> Tuple[int, int, int]:
|
|
if not isinstance(value, str):
|
|
return default
|
|
token = value.strip().lstrip("#")
|
|
if len(token) == 3:
|
|
token = "".join(ch * 2 for ch in token)
|
|
if len(token) != 6:
|
|
return default
|
|
try:
|
|
channels = tuple(int(token[index : index + 2], 16) for index in (0, 2, 4))
|
|
except ValueError:
|
|
return default
|
|
return channels # type: ignore[return-value]
|
|
|
|
|
|
def _resolve_background_colour(value: Optional[str], alpha_default: int) -> Optional[Tuple[int, int, int, int]]:
|
|
alpha = max(0, min(255, alpha_default))
|
|
presets = {
|
|
"black60": (0, 0, 0, int(round(255 * 0.6))),
|
|
"black80": (0, 0, 0, int(round(255 * 0.8))),
|
|
"white40": (255, 255, 255, int(round(255 * 0.4))),
|
|
}
|
|
if isinstance(value, str):
|
|
token = value.strip().lower()
|
|
if token in {"none", "transparent"}:
|
|
return None
|
|
preset = presets.get(token)
|
|
if preset is not None:
|
|
return preset
|
|
if token.startswith("#") or all(ch in "0123456789abcdef" for ch in token if ch != "#"):
|
|
red, green, blue = _parse_hex_colour(token, (0, 0, 0))
|
|
return (red, green, blue, alpha)
|
|
return (0, 0, 0, alpha)
|
|
|
|
|
|
def _load_preferred_font(family: Optional[str], size: int) -> Any:
|
|
if ImageFont is None: # pragma: no cover - Pillow optional
|
|
raise RuntimeError("Pillow ImageFont unavailable")
|
|
target_size = size if size > 0 else 18
|
|
candidates: List[str] = []
|
|
if isinstance(family, str) and family.strip():
|
|
candidate = family.strip()
|
|
candidates.append(candidate)
|
|
lowered = candidate.lower()
|
|
if not lowered.endswith((".ttf", ".otf", ".ttc")):
|
|
candidates.append(f"{candidate}.ttf")
|
|
candidates.append(f"{candidate}.otf")
|
|
if os.path.isfile(candidate):
|
|
candidates.insert(0, candidate)
|
|
candidates.extend(["DejaVuSans.ttf", "arial.ttf"])
|
|
for entry in candidates:
|
|
try:
|
|
return ImageFont.truetype(entry, target_size)
|
|
except Exception:
|
|
continue
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _axis_descriptor_label(descriptor: Optional[AxisDescriptor], fallback: str) -> str:
|
|
if descriptor is None:
|
|
return fallback
|
|
for candidate in (descriptor.source_label, descriptor.describe()):
|
|
text = str(candidate or "").strip()
|
|
if text and text not in {"identity", "∅"}:
|
|
return text
|
|
return fallback
|
|
|
|
|
|
def _normalise_label_text(raw: str, fallback: str) -> str:
|
|
text = str(raw or "").strip()
|
|
if not text or text.lower() == "identity" or text == "∅":
|
|
text = fallback
|
|
text = text.replace(" | ", "\n")
|
|
return text if text else fallback
|
|
|
|
|
|
def _wrap_label_lines(text: str, max_chars: int, limit: int = 4) -> List[str]:
|
|
width = max(1, max_chars)
|
|
lines: List[str] = []
|
|
for block in text.split("\n"):
|
|
candidate = block.strip()
|
|
if not candidate:
|
|
continue
|
|
wrapped = textwrap.wrap(candidate, width=width) or [candidate]
|
|
lines.extend(wrapped)
|
|
if len(lines) >= limit:
|
|
break
|
|
return lines[:limit] if lines else [text.strip() or "base"]
|
|
|
|
|
|
def _measure_text(
|
|
draw: Any,
|
|
text: str,
|
|
font: Any,
|
|
*,
|
|
spacing: int,
|
|
align: str,
|
|
) -> Tuple[int, int]:
|
|
if hasattr(draw, "multiline_textbbox"):
|
|
bbox = draw.multiline_textbbox((0, 0), text, font=font, spacing=spacing, align=align)
|
|
return bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
width, height = draw.multiline_textsize(text, font=font, spacing=spacing)
|
|
return width, height
|
|
|
|
|
|
def annotate_grid_image(
|
|
grid_tensor: torch.Tensor,
|
|
plans: List[GenerationPlan],
|
|
rows: int,
|
|
cols: int,
|
|
logger: ToolkitLogger,
|
|
style: Optional[Dict[str, Any]] = None,
|
|
x_axis: Optional[List[AxisDescriptor]] = None,
|
|
y_axis: Optional[List[AxisDescriptor]] = None,
|
|
) -> torch.Tensor:
|
|
if Image is None or ImageDraw is None or ImageFont is None:
|
|
logger.trace("Skipping grid annotation; Pillow not available")
|
|
return grid_tensor
|
|
if not plans:
|
|
return grid_tensor
|
|
|
|
style_data: Dict[str, Any] = dict(style or {})
|
|
layout = str(style_data.get("label_layout", "overlay") or "overlay").lower()
|
|
if layout not in {"overlay", "border", "none"}:
|
|
layout = "overlay"
|
|
if layout == "none":
|
|
return grid_tensor
|
|
|
|
font_size = int(style_data.get("font_size") or 18)
|
|
alignment = str(style_data.get("alignment", "left") or "left").lower()
|
|
if alignment not in {"left", "center", "right"}:
|
|
alignment = "left"
|
|
label_position = str(style_data.get("label_position", "top_left") or "top_left").lower()
|
|
if label_position not in {"top_left", "top_right", "bottom_left", "bottom_right"}:
|
|
label_position = "top_left"
|
|
background_rgba = _resolve_background_colour(style_data.get("background"), 200 if layout == "border" else 160)
|
|
text_colour = _parse_hex_colour(style_data.get("font_colour"), (255, 255, 255))
|
|
spacing = max(2, font_size // 6)
|
|
|
|
try:
|
|
font = _load_preferred_font(style_data.get("font_family"), font_size)
|
|
except Exception as exc: # pragma: no cover - font lookup failure
|
|
logger.debug(f"Font preference unavailable ({exc}); falling back to default")
|
|
font = ImageFont.load_default()
|
|
|
|
try:
|
|
device = grid_tensor.device
|
|
grid_cpu = grid_tensor.detach().to("cpu")
|
|
base = grid_cpu[0].float().clamp(0.0, 1.0)
|
|
image_array = (base.numpy() * 255.0).astype(np.uint8)
|
|
image = Image.fromarray(image_array)
|
|
except Exception as exc: # pragma: no cover - defensive guard
|
|
logger.warn(f"Unable to prepare grid for annotation: {exc}")
|
|
return grid_tensor
|
|
|
|
if layout == "border":
|
|
annotated_image = _render_border_layout(
|
|
image,
|
|
plans,
|
|
rows,
|
|
cols,
|
|
font,
|
|
text_colour,
|
|
background_rgba,
|
|
alignment,
|
|
spacing,
|
|
font_size,
|
|
style_data,
|
|
x_axis or [],
|
|
y_axis or [],
|
|
logger,
|
|
)
|
|
else:
|
|
annotated_image = _render_overlay_layout(
|
|
image,
|
|
plans,
|
|
rows,
|
|
cols,
|
|
font,
|
|
text_colour,
|
|
background_rgba,
|
|
alignment,
|
|
label_position,
|
|
spacing,
|
|
font_size,
|
|
)
|
|
|
|
annotated_rgb = annotated_image.convert("RGB") if annotated_image.mode != "RGB" else annotated_image
|
|
annotated_array = np.asarray(annotated_rgb, dtype=np.float32) / 255.0
|
|
annotated_tensor = torch.from_numpy(annotated_array).to(grid_tensor.dtype).unsqueeze(0)
|
|
return annotated_tensor.to(device)
|
|
|
|
|
|
def _render_overlay_layout(
|
|
image: Any,
|
|
plans: List[GenerationPlan],
|
|
rows: int,
|
|
cols: int,
|
|
font: Any,
|
|
text_colour: Tuple[int, int, int],
|
|
background_rgba: Optional[Tuple[int, int, int, int]],
|
|
alignment: str,
|
|
label_position: str,
|
|
spacing: int,
|
|
font_size: int,
|
|
) -> Any:
|
|
draw = ImageDraw.Draw(image, "RGBA")
|
|
cols_safe = max(1, cols)
|
|
rows_safe = max(1, rows)
|
|
cell_width = image.width // cols_safe
|
|
cell_height = image.height // rows_safe
|
|
margin = max(6, font_size // 3)
|
|
approx_char_width = max(font_size * 0.55, 6.0)
|
|
available_text_width = max(16, cell_width - (margin * 2))
|
|
max_chars = max(4, int(available_text_width / approx_char_width))
|
|
|
|
for index, plan in enumerate(plans):
|
|
row_index = index // cols_safe
|
|
col_index = index % cols_safe
|
|
cell_origin_x = col_index * cell_width
|
|
cell_origin_y = row_index * cell_height
|
|
fallback_label = f"Run {index + 1}"
|
|
label_text = _normalise_label_text(plan.label, fallback_label)
|
|
wrapped_lines = _wrap_label_lines(label_text, max_chars)
|
|
text_block = "\n".join(wrapped_lines)
|
|
text_width, text_height = _measure_text(draw, text_block, font, spacing=spacing, align=alignment)
|
|
rect_padding = 6
|
|
rect_width = text_width + rect_padding * 2
|
|
rect_height = text_height + rect_padding * 2
|
|
rect_width = min(rect_width, cell_width - margin)
|
|
rect_height = min(rect_height, cell_height - margin)
|
|
|
|
if label_position in {"top_left", "top_right"}:
|
|
rect_y = cell_origin_y + margin
|
|
else:
|
|
rect_y = cell_origin_y + cell_height - margin - rect_height
|
|
|
|
if label_position in {"top_left", "bottom_left"}:
|
|
rect_x = cell_origin_x + margin
|
|
else:
|
|
rect_x = cell_origin_x + cell_width - margin - rect_width
|
|
|
|
rect_x = max(cell_origin_x + 2, min(rect_x, cell_origin_x + cell_width - rect_width - 2))
|
|
rect_y = max(cell_origin_y + 2, min(rect_y, cell_origin_y + cell_height - rect_height - 2))
|
|
|
|
if background_rgba is not None and background_rgba[3] > 0:
|
|
draw.rectangle(
|
|
(
|
|
rect_x,
|
|
rect_y,
|
|
rect_x + rect_width,
|
|
rect_y + rect_height,
|
|
),
|
|
fill=background_rgba,
|
|
)
|
|
|
|
if alignment == "center":
|
|
text_x = rect_x + (rect_width - text_width) / 2
|
|
elif alignment == "right":
|
|
text_x = rect_x + rect_width - rect_padding - text_width
|
|
else:
|
|
text_x = rect_x + rect_padding
|
|
text_y = rect_y + (rect_height - text_height) / 2
|
|
draw.multiline_text(
|
|
(text_x, text_y),
|
|
text_block,
|
|
font=font,
|
|
fill=text_colour,
|
|
spacing=spacing,
|
|
align=alignment,
|
|
)
|
|
return image
|
|
|
|
|
|
def _render_border_layout(
|
|
image: Any,
|
|
plans: List[GenerationPlan],
|
|
rows: int,
|
|
cols: int,
|
|
font: Any,
|
|
text_colour: Tuple[int, int, int],
|
|
background_rgba: Optional[Tuple[int, int, int, int]],
|
|
alignment: str,
|
|
spacing: int,
|
|
font_size: int,
|
|
style: Dict[str, Any],
|
|
x_axis: List[AxisDescriptor],
|
|
y_axis: List[AxisDescriptor],
|
|
logger: ToolkitLogger,
|
|
) -> Any:
|
|
draw_probe = ImageDraw.Draw(image, "RGBA")
|
|
cols_safe = max(1, cols)
|
|
rows_safe = max(1, rows)
|
|
cell_width = image.width // cols_safe
|
|
cell_height = image.height // rows_safe
|
|
header_padding = max(6, font_size // 3)
|
|
|
|
col_labels: List[str] = []
|
|
if x_axis:
|
|
for index in range(min(len(x_axis), cols_safe)):
|
|
col_labels.append(_axis_descriptor_label(x_axis[index], f"Column {index + 1}"))
|
|
if len(col_labels) < cols_safe:
|
|
for index in range(len(col_labels), cols_safe):
|
|
fallback = plans[index].label if index < len(plans) else ""
|
|
col_labels.append(_axis_descriptor_label(None, _normalise_label_text(fallback, f"Column {index + 1}")))
|
|
|
|
row_labels: List[str] = []
|
|
if y_axis:
|
|
for index in range(min(len(y_axis), rows_safe)):
|
|
row_labels.append(_axis_descriptor_label(y_axis[index], f"Row {index + 1}"))
|
|
if len(row_labels) < rows_safe:
|
|
for index in range(len(row_labels), rows_safe):
|
|
plan_index = index * cols_safe
|
|
fallback = plans[plan_index].label if plan_index < len(plans) else ""
|
|
row_labels.append(_axis_descriptor_label(None, _normalise_label_text(fallback, f"Row {index + 1}")))
|
|
|
|
approx_char_width = max(font_size * 0.55, 6.0)
|
|
available_col_width = max(24, cell_width - header_padding * 2)
|
|
max_chars_col = max(4, int(available_col_width / approx_char_width))
|
|
|
|
col_blocks: List[Tuple[str, int, int]] = []
|
|
max_col_height = 0
|
|
for index, label in enumerate(col_labels):
|
|
text = _normalise_label_text(label, f"Column {index + 1}")
|
|
wrapped = _wrap_label_lines(text, max_chars_col)
|
|
text_block = "\n".join(wrapped)
|
|
width, height = _measure_text(draw_probe, text_block, font, spacing=spacing, align=alignment)
|
|
col_blocks.append((text_block, width, height))
|
|
max_col_height = max(max_col_height, height)
|
|
|
|
row_blocks: List[Tuple[str, int, int]] = []
|
|
max_row_width = 0
|
|
for index, label in enumerate(row_labels):
|
|
text_block = _normalise_label_text(label, f"Row {index + 1}")
|
|
width, height = _measure_text(draw_probe, text_block, font, spacing=spacing, align="left")
|
|
row_blocks.append((text_block, width, height))
|
|
max_row_width = max(max_row_width, width)
|
|
|
|
axis_headers_enabled = bool(style.get("show_axis_headers", True))
|
|
axis_x_text_block = ""
|
|
axis_y_text_block = ""
|
|
axis_x_metrics = (0, 0)
|
|
axis_y_metrics = (0, 0)
|
|
if axis_headers_enabled:
|
|
candidate_x = str(style.get("custom_label_x") or "").strip()
|
|
if candidate_x:
|
|
axis_x_text_block = _normalise_label_text(candidate_x, candidate_x)
|
|
axis_x_metrics = _measure_text(draw_probe, axis_x_text_block, font, spacing=spacing, align="center")
|
|
candidate_y = str(style.get("custom_label_y") or "").strip()
|
|
if candidate_y:
|
|
axis_y_text_block = _normalise_label_text(candidate_y, candidate_y)
|
|
axis_y_metrics = _measure_text(draw_probe, axis_y_text_block, font, spacing=spacing, align="left")
|
|
|
|
column_panel_height = (max_col_height + header_padding * 2) if col_blocks else 0
|
|
row_panel_width = (max_row_width + header_padding * 2) if row_blocks else 0
|
|
axis_x_panel_height = (axis_x_metrics[1] + header_padding * 2) if axis_x_text_block else 0
|
|
axis_y_panel_width = (axis_y_metrics[0] + header_padding * 2) if axis_y_text_block else 0
|
|
|
|
top_panel_height = int(axis_x_panel_height + column_panel_height)
|
|
left_panel_width = int(axis_y_panel_width + row_panel_width)
|
|
|
|
new_width = image.width + left_panel_width
|
|
new_height = image.height + top_panel_height
|
|
canvas = Image.new("RGBA", (new_width, new_height), (0, 0, 0, 0))
|
|
canvas.paste(image, (left_panel_width, top_panel_height))
|
|
draw_canvas = ImageDraw.Draw(canvas, "RGBA")
|
|
|
|
if background_rgba is not None and background_rgba[3] > 0:
|
|
draw_canvas.rectangle((0, 0, new_width, top_panel_height), fill=background_rgba)
|
|
draw_canvas.rectangle((0, top_panel_height, left_panel_width, new_height), fill=background_rgba)
|
|
|
|
text_fill = text_colour
|
|
|
|
if axis_x_text_block:
|
|
axis_x_x = left_panel_width + (image.width - axis_x_metrics[0]) / 2
|
|
axis_x_y = max(header_padding // 2, header_padding)
|
|
draw_canvas.multiline_text(
|
|
(axis_x_x, axis_x_y),
|
|
axis_x_text_block,
|
|
font=font,
|
|
fill=text_fill,
|
|
spacing=spacing,
|
|
align="center",
|
|
)
|
|
|
|
if axis_y_text_block:
|
|
axis_y_x = max(header_padding // 2, header_padding)
|
|
axis_y_y = top_panel_height + (image.height - axis_y_metrics[1]) / 2
|
|
draw_canvas.multiline_text(
|
|
(axis_y_x, axis_y_y),
|
|
axis_y_text_block,
|
|
font=font,
|
|
fill=text_fill,
|
|
spacing=spacing,
|
|
align="left",
|
|
)
|
|
|
|
column_offset_y = axis_x_panel_height if axis_x_panel_height else 0
|
|
for index, (text_block, width, height) in enumerate(col_blocks):
|
|
origin_x = left_panel_width + index * cell_width
|
|
if alignment == "center":
|
|
text_x = origin_x + (cell_width - width) / 2
|
|
elif alignment == "right":
|
|
text_x = origin_x + cell_width - header_padding - width
|
|
else:
|
|
text_x = origin_x + header_padding
|
|
text_y = column_offset_y + header_padding
|
|
draw_canvas.multiline_text(
|
|
(text_x, text_y),
|
|
text_block,
|
|
font=font,
|
|
fill=text_fill,
|
|
spacing=spacing,
|
|
align=alignment,
|
|
)
|
|
|
|
row_offset_x = axis_y_panel_width if axis_y_panel_width else 0
|
|
for index, (text_block, width, height) in enumerate(row_blocks):
|
|
origin_y = top_panel_height + index * cell_height
|
|
text_x = header_padding + row_offset_x
|
|
text_y = origin_y + (cell_height - height) / 2
|
|
text_y = max(top_panel_height + header_padding, text_y)
|
|
draw_canvas.multiline_text(
|
|
(text_x, text_y),
|
|
text_block,
|
|
font=font,
|
|
fill=text_fill,
|
|
spacing=spacing,
|
|
align="left",
|
|
)
|
|
|
|
return canvas
|
|
|
|
|
|
def _ensure_latent_dict(latent: Any) -> Dict[str, torch.Tensor]:
|
|
if isinstance(latent, dict):
|
|
return latent # type: ignore[return-value]
|
|
if isinstance(latent, (list, tuple)):
|
|
if not latent:
|
|
raise ValueError("Received empty latent container")
|
|
return _ensure_latent_dict(latent[0])
|
|
raise TypeError(f"Unsupported latent payload type: {type(latent)!r}")
|
|
|
|
|
|
def clone_latent(latent: Any) -> Dict[str, torch.Tensor]:
|
|
source = _ensure_latent_dict(latent)
|
|
return {key: value.clone() if torch.is_tensor(value) else value for key, value in source.items()}
|
|
|
|
|
|
def clone_conditioning_payload(conditioning: Any) -> Any:
|
|
if conditioning is None:
|
|
return None
|
|
if isinstance(conditioning, torch.Tensor):
|
|
return conditioning.clone()
|
|
if isinstance(conditioning, list):
|
|
return [clone_conditioning_payload(item) for item in conditioning]
|
|
if isinstance(conditioning, tuple):
|
|
return tuple(clone_conditioning_payload(item) for item in conditioning)
|
|
if isinstance(conditioning, dict):
|
|
return {key: clone_conditioning_payload(value) for key, value in conditioning.items()}
|
|
try:
|
|
return copy.deepcopy(conditioning)
|
|
except Exception:
|
|
return conditioning
|
|
|
|
|
|
def _clone_generic_payload(value: Any) -> Any:
|
|
if value is None:
|
|
return None
|
|
if torch.is_tensor(value):
|
|
return value.clone()
|
|
if isinstance(value, np.ndarray):
|
|
return value.copy()
|
|
if isinstance(value, dict):
|
|
return {key: _clone_generic_payload(item) for key, item in value.items()}
|
|
if isinstance(value, list):
|
|
return [_clone_generic_payload(item) for item in value]
|
|
if isinstance(value, tuple):
|
|
return tuple(_clone_generic_payload(item) for item in value)
|
|
if isinstance(value, set):
|
|
return {_clone_generic_payload(item) for item in value}
|
|
try:
|
|
return copy.deepcopy(value)
|
|
except Exception:
|
|
return value
|
|
|
|
|
|
class h4_PlotXY:
|
|
"""All-in-one checkpoint loader, sampler, scheduler, and grid plotter powering h4 : The Engine."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Any]: # noqa: N802 - ComfyUI naming convention
|
|
def list_safe(category: str) -> List[str]:
|
|
try:
|
|
return folder_paths.get_filename_list(category)
|
|
except Exception: # pragma: no cover - external environment
|
|
GLOBAL_LOGGER.warn(f"Unable to enumerate assets for category '{category}'")
|
|
return []
|
|
|
|
checkpoint_list = list_safe("checkpoints")
|
|
vae_list = list_safe("vae")
|
|
clip_list = list_safe("clip")
|
|
clip_vision_list = list_safe("clip_vision")
|
|
|
|
checkpoint_choices = (UI_DISABLED_TOKEN,) + tuple(checkpoint_list)
|
|
default_checkpoint = checkpoint_choices[1] if len(checkpoint_choices) > 1 else UI_DISABLED_TOKEN
|
|
vae_choices = (UI_DISABLED_TOKEN, "<checkpoint>") + tuple(vae_list)
|
|
clip_choices = (UI_DISABLED_TOKEN, "<checkpoint>") + tuple(clip_list)
|
|
clip_vision_choices = (UI_DISABLED_TOKEN, "<checkpoint>") + tuple(clip_vision_list)
|
|
|
|
return {
|
|
"required": {
|
|
"seed": ("INT", {"default": 0, "min": 0, "max": 2**32 - 1}),
|
|
"steps": ("INT", {"default": 20, "min": 1, "max": 150}),
|
|
"cfg": ("FLOAT", {"default": 7.5, "min": 1.0, "max": 30.0, "step": 0.1}),
|
|
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05}),
|
|
"clip_skip": ("INT", {"default": 2, "min": 1, "max": 12}),
|
|
"sampler_name": (SAMPLER_CHOICES, {"default": SAMPLER_CHOICES[0]}),
|
|
"scheduler_name": (SCHEDULER_CHOICES, {"default": SCHEDULER_CHOICES[0]}),
|
|
"width": ("INT", {"default": 512, "min": 64, "max": 2048, "step": 64}),
|
|
"height": ("INT", {"default": 512, "min": 64, "max": 2048, "step": 64}),
|
|
"checkpoint": (
|
|
checkpoint_choices,
|
|
{"default": default_checkpoint},
|
|
),
|
|
"vae_name": (vae_choices, {"default": "<checkpoint>"}),
|
|
"clip_text_name": (clip_choices, {"default": "<checkpoint>"}),
|
|
"clip_vision_name": (clip_vision_choices, {"default": "<checkpoint>"}),
|
|
"positive_prompt": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
},
|
|
),
|
|
"negative_prompt": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
},
|
|
),
|
|
"x_axis_mode": (AXIS_PRESETS, {"default": "prompt"}),
|
|
"x_axis_values": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
},
|
|
),
|
|
"y_axis_mode": (AXIS_PRESETS, {"default": "none"}),
|
|
"y_axis_values": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
},
|
|
),
|
|
"bypass_engine": ("BOOLEAN", {"default": False, "label": "Bypass (passthrough mode)"}),
|
|
},
|
|
"optional": {
|
|
"axis_x": ("STRING", {"default": "", "forceInput": True}),
|
|
"axis_y": ("STRING", {"default": "", "forceInput": True}),
|
|
"axis_z": ("STRING", {"default": "", "forceInput": True}),
|
|
"legacy_summary": ("STRING", {"default": "", "forceInput": True}),
|
|
"model_in": ("MODEL",),
|
|
"clip_in": ("CLIP",),
|
|
"vae_in": ("VAE",),
|
|
"conditioning_in": ("CONDITIONING",),
|
|
"conditioning_positive_in": ("CONDITIONING",),
|
|
"conditioning_negative_in": ("CONDITIONING",),
|
|
"latent_in": ("LATENT",),
|
|
"image_in": ("IMAGE",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "IMAGE", "MODEL", "CLIP", "VAE", "LATENT", "CONDITIONING", "CONDITIONING")
|
|
RETURN_NAMES = (
|
|
"Images",
|
|
"Grid_Image",
|
|
"Model",
|
|
"CLIP",
|
|
"VAE",
|
|
"Latent",
|
|
"Positive",
|
|
"Negative",
|
|
)
|
|
FUNCTION = "run_pipeline"
|
|
CATEGORY = "h4 Toolkit/Generation"
|
|
|
|
def __init__(self) -> None:
|
|
self.logger = ToolkitLogger("h4_Engine")
|
|
self.clip_encoder = comfy_nodes.CLIPTextEncode()
|
|
self.vae_decode = comfy_nodes.VAEDecode()
|
|
vae_encoder_cls = getattr(comfy_nodes, "VAELatentEncode", None) or getattr(
|
|
comfy_nodes, "VAEEncode", None
|
|
)
|
|
self.vae_encode = vae_encoder_cls() if vae_encoder_cls else None
|
|
if self.vae_encode is None:
|
|
self.logger.warn(
|
|
"Falling back to direct VAE.encode; plug a latent input for best stability."
|
|
)
|
|
self.empty_latent = comfy_nodes.EmptyLatentImage()
|
|
self.lora_loader = comfy_nodes.LoraLoader()
|
|
vae_loader_cls = getattr(comfy_nodes, "VAELoader", None)
|
|
self.vae_loader = vae_loader_cls() if callable(vae_loader_cls) else None
|
|
self.clip_loader = getattr(comfy_nodes, "CLIPLoader", None)
|
|
self.dual_clip_loader = getattr(comfy_nodes, "DualCLIPLoader", None)
|
|
self._clip_skip_warned = False
|
|
self._model_sampling_warned = False
|
|
self._sampler_object_warned = False
|
|
self._preview_handlers: Optional[Tuple[Optional[Callable[..., Any]], Optional[Callable[..., Any]]]] = None
|
|
self._preview_disabled_logged = False
|
|
self._preview_error_logged = False
|
|
self._preview_pil_missing_logged = False
|
|
preview_max = None
|
|
if latent_preview is not None:
|
|
preview_max = getattr(latent_preview, "MAX_PREVIEW_RESOLUTION", None)
|
|
if preview_max is None and comfy_args is not None:
|
|
preview_max = getattr(comfy_args, "preview_size", None)
|
|
if isinstance(preview_max, (int, float)):
|
|
preview_max_int = int(preview_max)
|
|
self._preview_max_resolution: Optional[int] = preview_max_int if preview_max_int > 0 else None
|
|
else:
|
|
self._preview_max_resolution = None
|
|
self._model_family_cache: Dict[int, str] = {}
|
|
|
|
def _load_checkpoint(self, checkpoint_name: str) -> Tuple[Any, Any, Any]:
|
|
resolved_path = _resolve_asset_path("checkpoints", checkpoint_name)
|
|
if resolved_path is None:
|
|
message = f"Checkpoint '{checkpoint_name}' could not be resolved"
|
|
self.logger.error(message)
|
|
raise FileNotFoundError(message)
|
|
self.logger.trace(f"Loading checkpoint: {resolved_path}")
|
|
checkpoint_family = _family_from_file(resolved_path, self.logger, "Checkpoint")
|
|
load_fn = getattr(comfy_sd, "load_checkpoint_guess_config", None)
|
|
if load_fn is None:
|
|
load_fn = getattr(comfy_sd, "load_checkpoint", None)
|
|
if load_fn is None:
|
|
raise RuntimeError("ComfyUI checkpoint loader is unavailable")
|
|
try:
|
|
bundle = load_fn(resolved_path, output_vae=True, output_clip=True)
|
|
except TypeError:
|
|
bundle = load_fn(resolved_path)
|
|
model = None
|
|
clip = None
|
|
vae = None
|
|
if isinstance(bundle, (list, tuple)):
|
|
if len(bundle) >= 3:
|
|
model, clip, vae = bundle[:3]
|
|
elif len(bundle) == 2:
|
|
model, clip = bundle
|
|
elif len(bundle) == 1:
|
|
model = bundle[0]
|
|
else:
|
|
model = bundle
|
|
if model is None:
|
|
raise RuntimeError(f"Checkpoint loader did not return a model for '{checkpoint_name}'")
|
|
if clip is None:
|
|
self.logger.warn("Checkpoint did not supply a CLIP; downstream overrides may fill this gap")
|
|
if vae is None:
|
|
self.logger.warn("Checkpoint did not supply a VAE; downstream overrides may fill this gap")
|
|
try:
|
|
setattr(model, "_h4_checkpoint_path", resolved_path)
|
|
except Exception: # pragma: no cover - attribute may be read-only
|
|
pass
|
|
try:
|
|
setattr(model, "_h4_checkpoint_name", checkpoint_name)
|
|
except Exception: # pragma: no cover - attribute may be read-only
|
|
pass
|
|
if checkpoint_family:
|
|
try:
|
|
setattr(model, "_h4_checkpoint_family", checkpoint_family)
|
|
except Exception: # pragma: no cover - attribute may be read-only
|
|
pass
|
|
self._model_family_cache[id(model)] = checkpoint_family
|
|
self.logger.info(f"Checkpoint ready: {checkpoint_name}")
|
|
return model, clip, vae
|
|
|
|
def _load_vae_override(self, current_vae: Any, vae_name: str) -> Any:
|
|
if not vae_name or vae_name in {"<checkpoint>", UI_DISABLED_TOKEN}:
|
|
return current_vae
|
|
resolved_path = _resolve_asset_path("vae", vae_name)
|
|
if resolved_path is None:
|
|
self.logger.warn(f"VAE override '{vae_name}' could not be resolved; using checkpoint VAE")
|
|
return current_vae
|
|
candidate_loaders: List[Tuple[Any, Tuple[Any, ...], Dict[str, Any], bool]] = []
|
|
direct_loader = getattr(comfy_sd, "load_vae", None)
|
|
if direct_loader is not None:
|
|
candidate_loaders.append((direct_loader, (resolved_path,), {}, True))
|
|
if self.vae_loader is not None:
|
|
candidate_loaders.append((self.vae_loader.load_vae, (vae_name,), {}, False)) # type: ignore[attr-defined]
|
|
for loader, args, kwargs, allow_path_kw in candidate_loaders:
|
|
try:
|
|
loaded = loader(*args, **kwargs) # type: ignore[misc]
|
|
except TypeError as exc:
|
|
if allow_path_kw:
|
|
try:
|
|
loaded = loader(path=args[0]) # type: ignore[misc]
|
|
except Exception as inner_exc: # pragma: no cover - loader specific
|
|
self.logger.warn(f"VAE loader signature mismatch ({loader}): {inner_exc}")
|
|
continue
|
|
else:
|
|
self.logger.warn(f"VAE loader signature mismatch ({loader}): {exc}")
|
|
continue
|
|
except Exception as exc: # pragma: no cover - loader specific
|
|
self.logger.warn(f"VAE loader failed ({loader}): {exc}")
|
|
continue
|
|
vae_obj = loaded[0] if isinstance(loaded, (list, tuple)) else loaded
|
|
if vae_obj is None:
|
|
continue
|
|
self.logger.info(f"Using VAE override: {vae_name}")
|
|
return vae_obj
|
|
self.logger.warn("All VAE loaders failed; falling back to checkpoint VAE")
|
|
return current_vae
|
|
|
|
def _load_clip_override(
|
|
self,
|
|
current_clip: Any,
|
|
clip_text_name: str,
|
|
clip_vision_name: str,
|
|
) -> Any:
|
|
text_override = clip_text_name not in (None, "", "<checkpoint>", UI_DISABLED_TOKEN)
|
|
vision_override = clip_vision_name not in (None, "", "<checkpoint>", UI_DISABLED_TOKEN)
|
|
if not text_override and not vision_override:
|
|
return current_clip
|
|
|
|
text_path = _resolve_asset_path("clip", clip_text_name) if text_override else None
|
|
vision_path = _resolve_asset_path("clip_vision", clip_vision_name) if vision_override else None
|
|
|
|
loader = getattr(comfy_sd, "load_clip", None)
|
|
last_error: Optional[str] = None
|
|
if loader is not None:
|
|
try:
|
|
loaded = loader(text_path, vision_path) # type: ignore[misc]
|
|
except TypeError:
|
|
try:
|
|
kwargs: Dict[str, Any] = {}
|
|
if text_path is not None:
|
|
kwargs["clip_path"] = text_path
|
|
if vision_path is not None:
|
|
kwargs["clip_vision_path"] = vision_path
|
|
loaded = loader(**kwargs) # type: ignore[misc]
|
|
except Exception as exc: # pragma: no cover - loader specific
|
|
last_error = str(exc)
|
|
loaded = None
|
|
except Exception as exc: # pragma: no cover - loader specific
|
|
last_error = str(exc)
|
|
loaded = None
|
|
if loaded is not None:
|
|
clip_obj = loaded[0] if isinstance(loaded, (list, tuple)) else loaded
|
|
if clip_obj is not None:
|
|
override_label = clip_text_name if text_override else "<checkpoint>"
|
|
if vision_override:
|
|
override_label = f"{override_label} / {clip_vision_name}"
|
|
self.logger.info(f"Using CLIP override: {override_label}")
|
|
return clip_obj
|
|
|
|
node_loader = None
|
|
if text_override:
|
|
if self.dual_clip_loader is not None:
|
|
node_loader = self.dual_clip_loader.load_clip # type: ignore[assignment]
|
|
elif self.clip_loader is not None and not vision_override:
|
|
node_loader = self.clip_loader.load_clip # type: ignore[assignment]
|
|
if node_loader is not None:
|
|
try:
|
|
loaded = node_loader(clip_text_name, clip_vision_name if vision_override else None) # type: ignore[misc]
|
|
clip_obj = loaded[0] if isinstance(loaded, (list, tuple)) else loaded
|
|
if clip_obj is not None:
|
|
override_label = clip_text_name if text_override else "<checkpoint>"
|
|
if vision_override:
|
|
override_label = f"{override_label} / {clip_vision_name}"
|
|
self.logger.info(f"Using CLIP override: {override_label}")
|
|
return clip_obj
|
|
except Exception as exc: # pragma: no cover - loader specific
|
|
last_error = str(exc)
|
|
|
|
if last_error:
|
|
self.logger.warn(f"Unable to load CLIP override: {last_error}")
|
|
else:
|
|
self.logger.warn("Clip override requested but no loader succeeded; using checkpoint clip")
|
|
return current_clip
|
|
|
|
def _apply_clip_skip(self, clip: Any, clip_skip: int) -> Any:
|
|
if clip_skip <= 1:
|
|
return clip
|
|
setter = getattr(comfy_utils, "set_clip_skip", None)
|
|
if setter is None and hasattr(clip, "set_clip_skip"):
|
|
setter = clip.set_clip_skip # type: ignore[assignment]
|
|
if setter is None and hasattr(clip, "clip") and hasattr(clip.clip, "set_clip_skip"):
|
|
setter = clip.clip.set_clip_skip # type: ignore[assignment]
|
|
if setter is None and hasattr(clip, "cond_stage_model") and hasattr(
|
|
clip.cond_stage_model, "set_clip_skip"
|
|
):
|
|
setter = clip.cond_stage_model.set_clip_skip # type: ignore[assignment]
|
|
if setter is None:
|
|
if not self._clip_skip_warned:
|
|
self._clip_skip_warned = True
|
|
self.logger.info(
|
|
"Clip skip helper not available in this environment; using checkpoint configuration"
|
|
)
|
|
return clip
|
|
try:
|
|
try:
|
|
result = setter(clip, clip_skip) # type: ignore[misc]
|
|
except TypeError:
|
|
result = setter(clip_skip) # type: ignore[misc]
|
|
if result is not None:
|
|
clip = result
|
|
self.logger.info(f"Clip skip set to {clip_skip}")
|
|
except Exception as exc: # pragma: no cover - setter specific
|
|
self.logger.warn(f"Clip skip update failed ({clip_skip}): {exc}")
|
|
return clip
|
|
|
|
def _encode_prompt(self, clip: Any, prompt: str) -> Any:
|
|
self.logger.trace(f"Encoding prompt: {prompt[:60]}...")
|
|
result = self.clip_encoder.encode(clip, prompt)
|
|
return result[0] if isinstance(result, tuple) else result
|
|
|
|
def _clone_conditioning(self, conditioning: Any) -> Any:
|
|
return clone_conditioning_payload(conditioning)
|
|
|
|
def _conditioning_to_device(self, conditioning: Any, device: torch.device) -> Any:
|
|
if conditioning is None:
|
|
return None
|
|
to_method = getattr(conditioning, "to", None)
|
|
if callable(to_method) and not isinstance(conditioning, torch.Tensor):
|
|
try:
|
|
return to_method(device=device)
|
|
except TypeError:
|
|
try:
|
|
return to_method(device)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
if torch.is_tensor(conditioning):
|
|
source_device = conditioning.device
|
|
try:
|
|
return conditioning.to(device=device)
|
|
except torch.cuda.OutOfMemoryError as exc:
|
|
if device.type != "cuda":
|
|
raise
|
|
self.logger.warn(
|
|
f"[DeviceCheck] Conditioning tensor move {source_device}->{device} OOM ({exc}); attempting recovery"
|
|
)
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
if conditioning.is_floating_point() and conditioning.dtype != torch.float16:
|
|
try:
|
|
return conditioning.to(device=device, dtype=torch.float16)
|
|
except torch.cuda.OutOfMemoryError as retry_exc:
|
|
self.logger.error(
|
|
f"[DeviceCheck] Conditioning tensor fallback to float16 failed ({retry_exc}); reraising"
|
|
)
|
|
raise
|
|
if isinstance(conditioning, list):
|
|
return [self._conditioning_to_device(item, device) for item in conditioning]
|
|
if isinstance(conditioning, tuple):
|
|
return tuple(self._conditioning_to_device(item, device) for item in conditioning)
|
|
if isinstance(conditioning, dict):
|
|
return {key: self._conditioning_to_device(value, device) for key, value in conditioning.items()}
|
|
return conditioning
|
|
|
|
def _latent_to_device(self, latent: Dict[str, Any], device: torch.device) -> Dict[str, Any]:
|
|
for key, value in list(latent.items()):
|
|
if torch.is_tensor(value):
|
|
latent[key] = value.to(device=device)
|
|
elif hasattr(value, "to") and callable(getattr(value, "to")):
|
|
to_method = getattr(value, "to")
|
|
try:
|
|
latent[key] = to_method(device=device)
|
|
except TypeError:
|
|
try:
|
|
latent[key] = to_method(device)
|
|
except Exception:
|
|
latent[key] = value
|
|
except Exception:
|
|
latent[key] = value
|
|
elif isinstance(value, dict):
|
|
latent[key] = self._latent_to_device(dict(value), device)
|
|
elif isinstance(value, list):
|
|
latent[key] = [self._conditioning_to_device(item, device) for item in value]
|
|
elif isinstance(value, tuple):
|
|
latent[key] = tuple(self._conditioning_to_device(item, device) for item in value)
|
|
return latent
|
|
|
|
# --- LANDMARK: Device proxy cache helpers ---
|
|
def _obtain_model_proxy(self, model: Any, device: torch.device) -> _DeviceAlignedModelProxy:
|
|
if isinstance(model, _DeviceAlignedModelProxy):
|
|
model.update_device(device)
|
|
return model
|
|
proxy = getattr(model, "_h4_device_proxy", None)
|
|
if isinstance(proxy, _DeviceAlignedModelProxy):
|
|
proxy.update_device(device)
|
|
return proxy
|
|
proxy = _DeviceAlignedModelProxy(model, device, self.logger)
|
|
try:
|
|
setattr(model, "_h4_device_proxy", proxy)
|
|
except Exception:
|
|
self.logger.debug("Device proxy memoization skipped; model does not expose attribute slot")
|
|
return proxy
|
|
|
|
def _release_model_proxy(self, model: Any) -> None:
|
|
if model is None:
|
|
return
|
|
proxy = getattr(model, "_h4_device_proxy", None)
|
|
if isinstance(proxy, _DeviceAlignedModelProxy):
|
|
try:
|
|
proxy.release()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
delattr(model, "_h4_device_proxy")
|
|
except AttributeError:
|
|
setattr(model, "_h4_device_proxy", None)
|
|
|
|
|
|
def _find_tensor_device_mismatches(
|
|
self,
|
|
payload: Any,
|
|
device: torch.device,
|
|
) -> List[Tuple[str, torch.device]]:
|
|
mismatches: List[Tuple[str, torch.device]] = []
|
|
if payload is None:
|
|
return mismatches
|
|
visited: Set[int] = set()
|
|
|
|
def _walk(value: Any, path: str) -> None:
|
|
obj_id = id(value)
|
|
if obj_id in visited:
|
|
return
|
|
visited.add(obj_id)
|
|
if torch.is_tensor(value):
|
|
if value.device != device:
|
|
mismatches.append((path or "<root>", value.device))
|
|
return
|
|
if isinstance(value, dict):
|
|
for key, item in value.items():
|
|
key_path = f"{path}.{key}" if path else str(key)
|
|
_walk(item, key_path)
|
|
return
|
|
if isinstance(value, (list, tuple)):
|
|
for index, item in enumerate(value):
|
|
key_path = f"{path}[{index}]" if path else f"[{index}]"
|
|
_walk(item, key_path)
|
|
return
|
|
attr_device = getattr(value, "device", None)
|
|
if isinstance(attr_device, torch.device) and attr_device != device:
|
|
mismatches.append((path or value.__class__.__name__, attr_device))
|
|
|
|
_walk(payload, "")
|
|
return mismatches
|
|
|
|
def _enforce_payload_device(
|
|
self,
|
|
label: str,
|
|
payload: Any,
|
|
device: torch.device,
|
|
converter: Callable[[Any, torch.device], Any],
|
|
) -> Any:
|
|
if payload is None:
|
|
return None
|
|
mismatches = self._find_tensor_device_mismatches(payload, device)
|
|
if not mismatches:
|
|
return payload
|
|
detail = ", ".join(f"{path}:{dev}" for path, dev in mismatches[:4])
|
|
self.logger.warn(
|
|
f"{label} contains tensors on unexpected device(s): {detail}; aligning to {device}."
|
|
)
|
|
adjusted = converter(payload, device)
|
|
remaining = self._find_tensor_device_mismatches(adjusted, device)
|
|
if remaining:
|
|
detail_remaining = ", ".join(f"{path}:{dev}" for path, dev in remaining[:4])
|
|
raise RuntimeError(
|
|
f"{label} still reports tensors on incorrect device(s) after correction: {detail_remaining}"
|
|
)
|
|
return adjusted
|
|
|
|
def _collect_tensor_devices(self, payload: Any) -> List[str]:
|
|
devices: Set[str] = set()
|
|
visited: Set[int] = set()
|
|
|
|
def _walk(value: Any) -> None:
|
|
obj_id = id(value)
|
|
if obj_id in visited:
|
|
return
|
|
visited.add(obj_id)
|
|
if torch.is_tensor(value):
|
|
devices.add(str(value.device))
|
|
return
|
|
if isinstance(value, dict):
|
|
for item in value.values():
|
|
_walk(item)
|
|
return
|
|
if isinstance(value, (list, tuple, set)):
|
|
for item in value:
|
|
_walk(item)
|
|
return
|
|
attr_device = getattr(value, "device", None)
|
|
if isinstance(attr_device, torch.device):
|
|
devices.add(str(attr_device))
|
|
|
|
_walk(payload)
|
|
return sorted(devices)
|
|
|
|
def _resolve_device(self, model: Any, fallback: torch.device) -> torch.device:
|
|
def _normalise_device(value: Any) -> Optional[torch.device]:
|
|
if isinstance(value, torch.device):
|
|
return value
|
|
if isinstance(value, str):
|
|
try:
|
|
return torch.device(value)
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
preferred_gpu: Optional[torch.device] = None
|
|
if torch.cuda.is_available():
|
|
try:
|
|
preferred_gpu = torch.device("cuda:0")
|
|
except Exception:
|
|
preferred_gpu = None
|
|
|
|
cpu_candidate: Optional[torch.device] = None
|
|
|
|
candidate = _normalise_device(getattr(model, "device", None))
|
|
if candidate is not None:
|
|
if candidate.type == "cuda":
|
|
return candidate
|
|
cpu_candidate = candidate
|
|
|
|
module_attr = getattr(model, "model", None)
|
|
if isinstance(module_attr, torch.nn.Module):
|
|
try:
|
|
module_param = next(module_attr.parameters())
|
|
except StopIteration:
|
|
module_param = None
|
|
except Exception:
|
|
module_param = None
|
|
if module_param is not None:
|
|
module_device = module_param.device
|
|
if module_device.type == "cuda":
|
|
return module_device
|
|
cpu_candidate = module_device
|
|
|
|
try:
|
|
resolved_raw = model_management.get_torch_device()
|
|
except Exception:
|
|
resolved_raw = None
|
|
resolved = _normalise_device(resolved_raw)
|
|
if resolved is not None:
|
|
if resolved.type == "cuda":
|
|
return resolved
|
|
cpu_candidate = resolved
|
|
|
|
if preferred_gpu is not None:
|
|
return preferred_gpu
|
|
if cpu_candidate is not None:
|
|
return cpu_candidate
|
|
return fallback
|
|
|
|
def _normalise_image_tensor(self, image: Any) -> torch.Tensor:
|
|
if isinstance(image, dict):
|
|
for key in ("image", "images", "pixels"):
|
|
if key in image:
|
|
return self._normalise_image_tensor(image[key])
|
|
raise TypeError("Image dictionary is missing 'image'/'images'/'pixels' keys")
|
|
|
|
if isinstance(image, np.ndarray):
|
|
tensor = torch.from_numpy(image)
|
|
elif torch.is_tensor(image):
|
|
tensor = image
|
|
else:
|
|
raise TypeError(f"Unsupported image payload type: {type(image)!r}")
|
|
|
|
tensor = tensor.to(dtype=torch.float32)
|
|
|
|
# Ensure a 4D tensor [batch, *, *, *]
|
|
while tensor.ndim < 4:
|
|
tensor = tensor.unsqueeze(0)
|
|
if tensor.ndim != 4:
|
|
raise ValueError(f"Image tensor must be rank 4 after normalisation, got shape {tuple(tensor.shape)}")
|
|
|
|
dims = list(tensor.shape)
|
|
spatial_axes = [1, 2, 3]
|
|
channel_axis_candidates: List[int] = []
|
|
for axis in spatial_axes:
|
|
dim = dims[axis]
|
|
if 0 < dim <= 4:
|
|
channel_axis_candidates.append(axis)
|
|
|
|
if not channel_axis_candidates:
|
|
# fall back to the smallest non-zero spatial dimension
|
|
positive_dims = [(axis, dims[axis]) for axis in spatial_axes if dims[axis] > 0]
|
|
if not positive_dims:
|
|
raise ValueError(f"Image tensor has zero-sized spatial dimensions: {tuple(dims)}")
|
|
channel_axis_candidates = [min(positive_dims, key=lambda item: item[1])[0]]
|
|
|
|
channel_axis = channel_axis_candidates[-1]
|
|
if channel_axis == 0:
|
|
channel_axis = 3
|
|
|
|
permute_order = [0] + [axis for axis in spatial_axes if axis != channel_axis] + [channel_axis]
|
|
bhwc = tensor.permute(*permute_order)
|
|
|
|
spatial_shape = bhwc.shape[1:3]
|
|
if 0 in spatial_shape:
|
|
raise ValueError(f"Normalised image tensor has zero-sized spatial dimension: {tuple(bhwc.shape)}")
|
|
|
|
bhwc = bhwc.contiguous()
|
|
if bhwc.dtype != torch.float32:
|
|
bhwc = bhwc.to(torch.float32)
|
|
|
|
max_val = float(bhwc.max().item())
|
|
min_val = float(bhwc.min().item())
|
|
if max_val > 1.01 or min_val < -0.01:
|
|
bhwc = bhwc / 255.0
|
|
max_val = float(bhwc.max().item())
|
|
min_val = float(bhwc.min().item())
|
|
bhwc = bhwc.clamp(0.0, 1.0)
|
|
if max_val > 1.0 or min_val < 0.0:
|
|
self.logger.warn(
|
|
f"Image payload outside expected range [{min_val:.3f}, {max_val:.3f}]; values clamped to [0, 1]"
|
|
)
|
|
return bhwc
|
|
|
|
def _prepare_image_payload(self, image: Any) -> Dict[str, torch.Tensor]:
|
|
if isinstance(image, (list, tuple)) and image:
|
|
image = image[0]
|
|
|
|
mask = None
|
|
if isinstance(image, dict):
|
|
if "samples" in image:
|
|
raise TypeError("Latent-like dictionary received where IMAGE payload was expected")
|
|
candidate = image.get("image") or image.get("images") or image.get("pixels")
|
|
if candidate is None:
|
|
raise KeyError("Image payload dictionary missing 'image'/'images'/'pixels' keys")
|
|
mask = image.get("mask")
|
|
normalised = self._normalise_image_tensor(candidate)
|
|
else:
|
|
normalised = self._normalise_image_tensor(image)
|
|
|
|
payload: Dict[str, torch.Tensor] = {"image": normalised, "images": normalised}
|
|
if mask is not None:
|
|
payload["mask"] = mask
|
|
return payload
|
|
|
|
def _prepare_latent(
|
|
self,
|
|
vae: Any,
|
|
width: int,
|
|
height: int,
|
|
latent_in: Optional[Dict[str, torch.Tensor]],
|
|
image_in: Optional[torch.Tensor],
|
|
) -> Dict[str, torch.Tensor]:
|
|
width_aligned = max(8, ((int(width) + 7) // 8) * 8)
|
|
height_aligned = max(8, ((int(height) + 7) // 8) * 8)
|
|
if width_aligned != width or height_aligned != height:
|
|
self.logger.warn(
|
|
f"Latent dimensions realigned to VAE requirements: {width}x{height} -> {width_aligned}x{height_aligned}"
|
|
)
|
|
width = width_aligned
|
|
height = height_aligned
|
|
if latent_in is not None:
|
|
self.logger.info("Using supplied latent as base")
|
|
return clone_latent(latent_in)
|
|
if image_in is not None:
|
|
if isinstance(image_in, dict) and "samples" in image_in:
|
|
self.logger.info("Image input already in latent format; using directly")
|
|
return clone_latent(image_in)
|
|
self.logger.info("Encoding supplied image into latent")
|
|
try:
|
|
image_payload = self._prepare_image_payload(image_in)
|
|
except Exception as exc:
|
|
raise RuntimeError(f"Unable to normalise image input for latent encoding: {exc}") from exc
|
|
self.logger.trace(
|
|
f"Image payload normalised shape: {tuple(image_payload['image'].shape)}"
|
|
)
|
|
image_tensor = image_payload["image"].to(dtype=torch.float32)
|
|
if self.vae_encode is not None:
|
|
try:
|
|
latent = self.vae_encode.encode(vae, image_tensor)
|
|
return clone_latent(latent)
|
|
except Exception as exc:
|
|
self.logger.warn(f"VAELatentEncode failed after normalisation; attempting direct encode path ({exc})")
|
|
if hasattr(vae, "encode"):
|
|
self.logger.trace(f"Direct encode input BHWC shape: {tuple(image_tensor.shape)}")
|
|
pixels = image_tensor.permute(0, 3, 1, 2).contiguous()
|
|
self.logger.trace(f"Direct encode input BCHW shape: {tuple(pixels.shape)}")
|
|
try:
|
|
encoded = vae.encode(pixels)
|
|
except TypeError:
|
|
encoded = vae.encode(pixels)
|
|
if isinstance(encoded, (dict, list, tuple)):
|
|
return clone_latent(encoded)
|
|
return clone_latent({"samples": encoded})
|
|
raise RuntimeError("Active VAE cannot encode images into latents; please supply a latent input.")
|
|
self.logger.info("Creating empty latent with requested dimensions")
|
|
latent = self.empty_latent.generate(width, height, 1)
|
|
return clone_latent(latent)
|
|
|
|
def _apply_loras(
|
|
self,
|
|
model: Any,
|
|
clip: Any,
|
|
loras: Iterable[Tuple[str, float]],
|
|
) -> Tuple[Any, Any]:
|
|
patched_model, patched_clip = model, clip
|
|
lora_sequence = list(loras)
|
|
if not lora_sequence:
|
|
return patched_model, patched_clip
|
|
|
|
def _record_family(target: Any, family: Optional[str]) -> None:
|
|
if target is None or family is None:
|
|
return
|
|
try:
|
|
setattr(target, "_h4_checkpoint_family", family)
|
|
except Exception: # pragma: no cover - attribute may be read-only
|
|
pass
|
|
self._model_family_cache[id(target)] = family
|
|
|
|
active_family: Optional[str] = self._model_family_cache.get(id(model))
|
|
if active_family is None:
|
|
candidate = getattr(model, "_h4_checkpoint_family", None)
|
|
active_family = _normalise_model_family_tag(candidate)
|
|
if active_family is None:
|
|
inferred = _infer_family_from_model_object(model)
|
|
active_family = inferred
|
|
if active_family is None:
|
|
self.logger.info(
|
|
"Checkpoint family could not be determined; LoRA compatibility checks will be best-effort"
|
|
)
|
|
else:
|
|
_record_family(model, active_family)
|
|
|
|
for lora_name, strength in lora_sequence:
|
|
if strength == 0:
|
|
self.logger.trace(f"Skipping LoRA {lora_name} with zero strength")
|
|
continue
|
|
resolved_path = _resolve_asset_path("loras", lora_name) or lora_name
|
|
lora_family = _family_from_file(resolved_path, self.logger, "LoRA")
|
|
if lora_family:
|
|
self.logger.trace(f"LoRA {lora_name} declared family '{lora_family}'")
|
|
else:
|
|
self.logger.trace(f"LoRA {lora_name} has no detectable family metadata")
|
|
if active_family and lora_family and active_family != lora_family:
|
|
message = (
|
|
f"LoRA '{lora_name}' targets '{lora_family}' but checkpoint family is '{active_family}'"
|
|
)
|
|
self.logger.error(message)
|
|
raise RuntimeError(message)
|
|
if active_family is None and lora_family:
|
|
active_family = lora_family
|
|
self.logger.info(
|
|
f"Assuming checkpoint family '{active_family}' based on LoRA '{lora_name}' metadata"
|
|
)
|
|
self.logger.trace(f"Applying LoRA {lora_name} @ {strength}")
|
|
patched_model, patched_clip = self.lora_loader.load_lora(
|
|
patched_model, patched_clip, lora_name, strength, strength
|
|
)
|
|
if active_family:
|
|
_record_family(patched_model, active_family)
|
|
return patched_model, patched_clip
|
|
|
|
def _get_preview_handlers(self) -> Tuple[Optional[Callable[..., Any]], Optional[Callable[..., Any]]]:
|
|
if self._preview_handlers is not None:
|
|
return self._preview_handlers
|
|
|
|
decoder_candidates: List[Optional[Callable[..., Any]]] = []
|
|
publisher_candidates: List[Optional[Callable[..., Any]]] = []
|
|
|
|
if latent_preview is not None:
|
|
for name in ("decode_latent_preview", "decode_latent", "decode"):
|
|
decoder_candidates.append(getattr(latent_preview, name, None))
|
|
for name in ("publish_preview", "publish"):
|
|
publisher_candidates.append(getattr(latent_preview, name, None))
|
|
|
|
decoder_candidates.append(getattr(comfy_utils, "decode_latent_preview", None))
|
|
publisher_candidates.extend(
|
|
[
|
|
getattr(comfy_utils, "publish_preview", None),
|
|
getattr(comfy_utils, "publish_preview_image", None),
|
|
]
|
|
)
|
|
|
|
def pick_callable(candidates: List[Optional[Callable[..., Any]]]) -> Optional[Callable[..., Any]]:
|
|
for candidate in candidates:
|
|
if callable(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
decoder_callable = pick_callable(decoder_candidates)
|
|
publisher_callable = pick_callable(publisher_candidates)
|
|
|
|
if decoder_callable is None:
|
|
def decode_with_vae(vae_obj: Any, latent_tensor: torch.Tensor) -> torch.Tensor:
|
|
latent_dict = {"samples": latent_tensor}
|
|
return self.vae_decode.decode(vae_obj, latent_dict)[0]
|
|
|
|
decoder_callable = decode_with_vae
|
|
|
|
self._preview_handlers = (decoder_callable, publisher_callable)
|
|
return self._preview_handlers
|
|
|
|
def _render_preview_tuple(
|
|
self,
|
|
image_like: Any,
|
|
) -> Optional[Tuple[str, Any, Optional[int]]]:
|
|
if Image is None:
|
|
if not self._preview_pil_missing_logged:
|
|
self.logger.info("Pillow is unavailable; previews will be limited to progress updates only")
|
|
self._preview_pil_missing_logged = True
|
|
return None
|
|
try:
|
|
normalised = self._normalise_image_tensor(image_like)
|
|
except Exception as exc:
|
|
if TRACE_ENABLED:
|
|
self.logger.trace(f"Preview normalisation failed: {exc}")
|
|
return None
|
|
try:
|
|
sample = normalised[0].detach().to(device="cpu", dtype=torch.float32)
|
|
except Exception:
|
|
try:
|
|
sample = normalised.detach().to(device="cpu", dtype=torch.float32)
|
|
if sample.ndim >= 4:
|
|
sample = sample[0]
|
|
except Exception as exc:
|
|
if TRACE_ENABLED:
|
|
self.logger.trace(f"Preview tensor conversion failed: {exc}")
|
|
return None
|
|
sample = sample.clamp(0.0, 1.0)
|
|
data = sample.cpu().numpy()
|
|
if data.ndim == 2:
|
|
data = np.expand_dims(data, axis=-1)
|
|
if data.ndim != 3:
|
|
if TRACE_ENABLED:
|
|
self.logger.trace(f"Unexpected preview tensor rank: {data.ndim}")
|
|
return None
|
|
channels = data.shape[-1]
|
|
if channels == 1:
|
|
data = np.repeat(data, 3, axis=-1)
|
|
elif channels == 2:
|
|
padding = np.zeros((*data.shape[:-1], 1), dtype=data.dtype)
|
|
data = np.concatenate([data, padding], axis=-1)
|
|
data_uint8 = np.clip(data * 255.0, 0, 255).astype(np.uint8)
|
|
try:
|
|
image = Image.fromarray(data_uint8)
|
|
except Exception as exc:
|
|
if TRACE_ENABLED:
|
|
self.logger.trace(f"PIL conversion failed: {exc}")
|
|
return None
|
|
return ("JPEG", image, self._preview_max_resolution)
|
|
|
|
def _run_sampler(
|
|
self,
|
|
model: Any,
|
|
vae: Any,
|
|
positive: Any,
|
|
negative: Any,
|
|
latent: Dict[str, torch.Tensor],
|
|
seed: int,
|
|
steps: int,
|
|
cfg: float,
|
|
denoise: float,
|
|
sampler_name: str,
|
|
scheduler_name: str,
|
|
) -> Dict[str, torch.Tensor]:
|
|
samples = latent.get("samples")
|
|
if not isinstance(samples, torch.Tensor):
|
|
raise RuntimeError("Latent payload is missing a 'samples' tensor for sampling")
|
|
generator = torch.Generator(device=samples.device).manual_seed(int(seed))
|
|
try:
|
|
noise = torch.randn_like(samples, generator=generator)
|
|
except TypeError:
|
|
noise = torch.randn(
|
|
samples.shape,
|
|
dtype=samples.dtype,
|
|
device=samples.device,
|
|
generator=generator,
|
|
)
|
|
except RuntimeError as exc:
|
|
self.logger.warn(f"Noise sampling fallback engaged: {exc}")
|
|
devices = [samples.device] if samples.is_cuda else []
|
|
with torch.random.fork_rng(devices=devices):
|
|
torch.manual_seed(int(seed))
|
|
noise = torch.randn_like(samples)
|
|
if noise.device != samples.device:
|
|
noise = noise.to(device=samples.device)
|
|
preview_token = f"engine-preview-{time.time():.0f}-{seed}"
|
|
node_id_for_pbar: Optional[str] = None
|
|
if callable(get_executing_context): # pragma: no branch - optional context
|
|
try:
|
|
context = get_executing_context()
|
|
except Exception: # pragma: no cover - defensive
|
|
context = None
|
|
if context is not None:
|
|
node_id_for_pbar = getattr(context, "node_id", None)
|
|
pbar = comfy_utils.ProgressBar(steps, node_id=node_id_for_pbar)
|
|
|
|
def callback(step: int, x0: torch.Tensor, *_args: Any) -> None:
|
|
self.logger.trace(f"Preview step {step}/{steps}")
|
|
decoder, publisher = self._get_preview_handlers()
|
|
decoded = None
|
|
if decoder is not None:
|
|
try:
|
|
try:
|
|
decoded = decoder(vae, x0)
|
|
except TypeError:
|
|
decoded = decoder(x0) # type: ignore[misc]
|
|
except Exception as exc: # pragma: no cover - best effort
|
|
if not self._preview_error_logged:
|
|
self.logger.warn(f"Preview decode failed; disabling thumbnails ({exc})")
|
|
self._preview_error_logged = True
|
|
decoded = None
|
|
self._preview_handlers = None
|
|
elif not self._preview_disabled_logged:
|
|
self.logger.info("Preview decoder unavailable; live previews disabled")
|
|
self._preview_disabled_logged = True
|
|
|
|
preview_payload: Optional[Tuple[str, Any, Optional[int]]] = None
|
|
if decoded is not None:
|
|
preview_payload = self._render_preview_tuple(decoded)
|
|
if publisher is not None:
|
|
try:
|
|
publisher(decoded, preview_token)
|
|
except Exception as exc: # pragma: no cover - best effort only
|
|
if not self._preview_error_logged:
|
|
self.logger.warn(f"Preview publishing disabled after error: {exc}")
|
|
self._preview_error_logged = True
|
|
self._preview_handlers = (decoder, None)
|
|
elif preview_payload is None and not self._preview_disabled_logged:
|
|
self.logger.info("Preview renderer unavailable; showing progress without thumbnails")
|
|
self._preview_disabled_logged = True
|
|
pbar.update_absolute(step + 1, steps, preview_payload)
|
|
|
|
self.logger.info(
|
|
f"Sampling with {sampler_name} + {scheduler_name}, steps={steps}, cfg={cfg:.2f}, denoise={denoise:.2f}"
|
|
)
|
|
model_sampling = _extract_model_sampling(model)
|
|
model_sampling_meta: Dict[str, Any] = {}
|
|
if model_sampling is not None:
|
|
for attr in (
|
|
"sigma_min",
|
|
"sigma_max",
|
|
"sigma_data",
|
|
"sigma_schedule",
|
|
"schedule",
|
|
"model_type",
|
|
):
|
|
value = _callable_or_value(getattr(model_sampling, attr, None))
|
|
if value is not None:
|
|
model_sampling_meta[attr] = value
|
|
model_type_value = _callable_or_value(getattr(model, "model_type", None))
|
|
if model_type_value is not None:
|
|
model_sampling_meta.setdefault("model_type", model_type_value)
|
|
if model_sampling is None and not self._model_sampling_warned:
|
|
self._model_sampling_warned = True
|
|
self.logger.warn(
|
|
"Unable to determine model sampling metadata; attempting sampler call without explicit type"
|
|
)
|
|
latent_tensor = latent.get("samples", latent)
|
|
sampler_kwargs = {
|
|
"model": model,
|
|
"noise": noise,
|
|
"steps": steps,
|
|
"cfg": cfg,
|
|
"sampler_name": sampler_name,
|
|
"scheduler_name": scheduler_name,
|
|
"scheduler": scheduler_name,
|
|
"positive": positive,
|
|
"negative": negative,
|
|
"latent": latent_tensor,
|
|
"seed": seed,
|
|
"denoise": denoise,
|
|
"disable_noise": False,
|
|
"start_step": 0,
|
|
"last_step": -1,
|
|
"force_full_denoise": False,
|
|
"callback": callback,
|
|
"device": samples.device,
|
|
}
|
|
if model_sampling is not None:
|
|
sampler_kwargs["model_sampling"] = model_sampling
|
|
if model_sampling_meta:
|
|
sampler_kwargs.update({key: value for key, value in model_sampling_meta.items() if key not in sampler_kwargs})
|
|
|
|
sampler_object = _resolve_sampler_object(
|
|
self.logger,
|
|
sampler_name,
|
|
scheduler_name,
|
|
model,
|
|
device=samples.device,
|
|
model_sampling=model_sampling,
|
|
model_sampling_meta=model_sampling_meta,
|
|
)
|
|
if sampler_object is not None:
|
|
sampler_kwargs["sampler"] = sampler_object
|
|
self.logger.trace(
|
|
f"Resolved sampler object for '{sampler_name}'/{scheduler_name}: {sampler_object}"
|
|
)
|
|
elif not self._sampler_object_warned:
|
|
self._sampler_object_warned = True
|
|
self.logger.warn(
|
|
"Sampler factory unavailable; relying on compatibility shim with sampler name only"
|
|
)
|
|
|
|
sigma_generator = getattr(comfy_samplers, "calculate_sigmas", None)
|
|
if sigma_generator is not None:
|
|
try:
|
|
try:
|
|
sigma_signature = inspect.signature(sigma_generator)
|
|
except (TypeError, ValueError):
|
|
sigma_signature = None
|
|
|
|
available_values: Dict[str, Any] = {
|
|
"sampler": sampler_name,
|
|
"sampler_name": sampler_name,
|
|
"sampler_type": sampler_name,
|
|
"scheduler": scheduler_name,
|
|
"scheduler_name": scheduler_name,
|
|
"steps": steps,
|
|
"num_steps": steps,
|
|
"step_count": steps,
|
|
"start_step": 0,
|
|
"first_step": 0,
|
|
"last_step": -1,
|
|
"end_step": -1,
|
|
"force_full_denoise": False,
|
|
"full_denoise": False,
|
|
"denoise": denoise,
|
|
"denoise_strength": denoise,
|
|
"device": samples.device,
|
|
"model": model,
|
|
"noise": noise,
|
|
"latent": latent,
|
|
}
|
|
if model_sampling is not None:
|
|
available_values["model_sampling"] = model_sampling
|
|
for key, value in model_sampling_meta.items():
|
|
available_values.setdefault(key, value)
|
|
if model_type_value is not None:
|
|
available_values.setdefault("model_type", model_type_value)
|
|
|
|
sigma_args: List[Any] = []
|
|
sigma_kwargs: Dict[str, Any] = {}
|
|
missing_required: List[str] = []
|
|
|
|
if sigma_signature is not None:
|
|
for name, param in sigma_signature.parameters.items():
|
|
if param.kind == param.VAR_POSITIONAL:
|
|
continue
|
|
if param.kind == param.VAR_KEYWORD:
|
|
continue
|
|
value = available_values.get(name)
|
|
if value is None:
|
|
if param.default is inspect._empty and param.kind in (
|
|
param.POSITIONAL_ONLY,
|
|
param.POSITIONAL_OR_KEYWORD,
|
|
param.KEYWORD_ONLY,
|
|
):
|
|
missing_required.append(name)
|
|
continue
|
|
if param.kind == param.POSITIONAL_ONLY:
|
|
sigma_args.append(value)
|
|
elif param.kind == param.POSITIONAL_OR_KEYWORD:
|
|
sigma_kwargs[name] = value
|
|
elif param.kind == param.KEYWORD_ONLY:
|
|
sigma_kwargs[name] = value
|
|
else:
|
|
sigma_kwargs = {
|
|
"sampler_name": sampler_name,
|
|
"scheduler_name": scheduler_name,
|
|
"steps": steps,
|
|
"start_step": 0,
|
|
"last_step": -1,
|
|
"force_full_denoise": False,
|
|
"denoise": denoise,
|
|
}
|
|
if model_sampling is not None:
|
|
sigma_kwargs["model_sampling"] = model_sampling
|
|
for key, value in model_sampling_meta.items():
|
|
sigma_kwargs.setdefault(key, value)
|
|
if model_type_value is not None:
|
|
sigma_kwargs.setdefault("model_type", model_type_value)
|
|
|
|
if missing_required:
|
|
raise RuntimeError(
|
|
"calculate_sigmas signature requires unsupported parameters: "
|
|
+ ", ".join(missing_required)
|
|
)
|
|
|
|
sigmas = sigma_generator(*sigma_args, **sigma_kwargs)
|
|
if isinstance(sigmas, (list, tuple)):
|
|
sigmas = torch.tensor(sigmas, dtype=samples.dtype, device=samples.device)
|
|
elif hasattr(sigmas, "to"):
|
|
sigmas = sigmas.to(device=samples.device, dtype=samples.dtype)
|
|
sampler_kwargs["sigmas"] = sigmas
|
|
except Exception as exc: # pragma: no cover - best effort
|
|
self.logger.warn(f"Unable to precompute sigma schedule: {exc}")
|
|
result = _invoke_sampler_with_compatibility(self.logger, sampler_kwargs)
|
|
if isinstance(result, torch.Tensor):
|
|
result = {"samples": result}
|
|
return result
|
|
|
|
def _decode(self, vae: Any, latent: Dict[str, torch.Tensor]) -> torch.Tensor:
|
|
images = self.vae_decode.decode(vae, latent)[0]
|
|
self.logger.trace("Decoded latent into image tensor")
|
|
return images
|
|
|
|
def _build_report(
|
|
self,
|
|
plans: List[GenerationPlan],
|
|
results: List[Dict[str, Any]],
|
|
grid_shape: Tuple[int, int],
|
|
) -> str:
|
|
payload = {
|
|
"node": "h4_PlotXY",
|
|
"version": PLOT_NODE_VERSION,
|
|
"grid": {"rows": grid_shape[0], "columns": grid_shape[1]},
|
|
"runs": [
|
|
{
|
|
"plan": plan.as_dict(),
|
|
"result": summary,
|
|
}
|
|
for plan, summary in zip(plans, results)
|
|
],
|
|
}
|
|
report = json.dumps(payload, indent=2)
|
|
self.logger.info("Execution report generated")
|
|
return report
|
|
|
|
def run_pipeline(
|
|
self,
|
|
seed: int,
|
|
steps: int,
|
|
cfg: float,
|
|
denoise: float,
|
|
clip_skip: int,
|
|
sampler_name: str,
|
|
scheduler_name: str,
|
|
width: int,
|
|
height: int,
|
|
checkpoint: str,
|
|
vae_name: str,
|
|
clip_text_name: str,
|
|
clip_vision_name: str,
|
|
positive_prompt: str,
|
|
negative_prompt: str,
|
|
x_axis_mode: str,
|
|
x_axis_values: str,
|
|
y_axis_mode: str,
|
|
y_axis_values: str,
|
|
bypass_engine: bool,
|
|
axis_x: Optional[str] = None,
|
|
axis_y: Optional[str] = None,
|
|
axis_z: Optional[str] = None,
|
|
legacy_summary: Optional[str] = None,
|
|
model_in: Optional[Any] = None,
|
|
clip_in: Optional[Any] = None,
|
|
vae_in: Optional[Any] = None,
|
|
conditioning_in: Optional[Any] = None,
|
|
conditioning_positive_in: Optional[Any] = None,
|
|
conditioning_negative_in: Optional[Any] = None,
|
|
latent_in: Optional[Dict[str, torch.Tensor]] = None,
|
|
image_in: Optional[torch.Tensor] = None,
|
|
) -> Tuple[
|
|
torch.Tensor,
|
|
torch.Tensor,
|
|
Any,
|
|
Any,
|
|
Any,
|
|
Dict[str, torch.Tensor],
|
|
Any,
|
|
Any,
|
|
]:
|
|
self.logger.info("Engine pipeline starting")
|
|
|
|
# --- LANDMARK: Selection normalisation helpers ---
|
|
def _normalise_checkpoint(value: Optional[str]) -> Optional[str]:
|
|
candidate = str(value or "").strip()
|
|
if not candidate or candidate.upper() == UI_DISABLED_TOKEN:
|
|
return None
|
|
return candidate
|
|
|
|
def _resolve_model_checkpoint_tag(model_obj: Any) -> Optional[str]:
|
|
if model_obj is None:
|
|
return None
|
|
candidates = (
|
|
getattr(model_obj, "_h4_checkpoint_name", None),
|
|
getattr(model_obj, "_h4_checkpoint_path", None),
|
|
getattr(model_obj, "checkpoint_name", None),
|
|
)
|
|
for candidate in candidates:
|
|
if isinstance(candidate, str) and candidate.strip():
|
|
value = candidate.strip()
|
|
return os.path.basename(value) if os.path.sep in value else value
|
|
inner = getattr(model_obj, "model", None)
|
|
if inner is not None and inner is not model_obj:
|
|
return _resolve_model_checkpoint_tag(inner)
|
|
return None
|
|
|
|
def _locate_component(root: Any, attrs: Tuple[str, ...]) -> Optional[Any]:
|
|
for attr in attrs:
|
|
if hasattr(root, attr):
|
|
value = getattr(root, attr)
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
def _extract_embedded_components(model_obj: Any) -> Tuple[Optional[Any], Optional[Any]]:
|
|
if model_obj is None:
|
|
return (None, None)
|
|
clip_candidate = _locate_component(
|
|
model_obj,
|
|
("clip", "cond_stage_model", "clip_model", "text_encoder"),
|
|
)
|
|
if clip_candidate is None:
|
|
inner = getattr(model_obj, "model", None)
|
|
if inner is not None and inner is not model_obj:
|
|
clip_candidate = _locate_component(
|
|
inner,
|
|
("clip", "cond_stage_model", "clip_model", "text_encoder"),
|
|
)
|
|
vae_candidate = _locate_component(
|
|
model_obj,
|
|
("first_stage_model", "vae", "autoencoder", "decoder"),
|
|
)
|
|
if vae_candidate is None:
|
|
inner = getattr(model_obj, "model", None)
|
|
if inner is not None and inner is not model_obj:
|
|
vae_candidate = _locate_component(
|
|
inner,
|
|
("first_stage_model", "vae", "autoencoder", "decoder"),
|
|
)
|
|
return clip_candidate, vae_candidate
|
|
|
|
selected_checkpoint = _normalise_checkpoint(checkpoint)
|
|
base_model_tag = _resolve_model_checkpoint_tag(model_in)
|
|
primary_checkpoint_name = selected_checkpoint or base_model_tag
|
|
prefer_inputs_for_base = bool(model_in is not None)
|
|
base_key = "__input__" if prefer_inputs_for_base else (primary_checkpoint_name or "__anonymous__")
|
|
if base_key == "__anonymous__":
|
|
raise RuntimeError("No checkpoint selected and no MODEL input supplied.")
|
|
|
|
active_bundle_key: Optional[str] = None
|
|
active_bundle: Optional[Tuple[Any, Any, Any]] = None
|
|
|
|
# --- LANDMARK: Model bundle cache ---
|
|
def release_active_bundle() -> None:
|
|
nonlocal active_bundle_key, active_bundle
|
|
if active_bundle_key is None:
|
|
return
|
|
if active_bundle_key != "__input__":
|
|
self.logger.info("Clearing previous checkpoint from memory")
|
|
model_management.soft_empty_cache()
|
|
if torch.cuda.is_available(): # pragma: no cover - device-specific
|
|
torch.cuda.empty_cache()
|
|
if active_bundle is not None:
|
|
self._release_model_proxy(active_bundle[0])
|
|
active_bundle_key = None
|
|
active_bundle = None
|
|
|
|
def ensure_bundle(target_key: str, target_checkpoint: Optional[str], prefer_inputs: bool) -> Tuple[Any, Any, Any]:
|
|
nonlocal active_bundle_key, active_bundle
|
|
if active_bundle_key == target_key and active_bundle is not None:
|
|
return active_bundle
|
|
if active_bundle_key != target_key:
|
|
release_active_bundle()
|
|
|
|
base_model: Optional[Any] = None
|
|
base_clip: Optional[Any] = None
|
|
base_vae: Optional[Any] = None
|
|
clip_from_input = False
|
|
vae_from_input = False
|
|
|
|
if prefer_inputs:
|
|
if model_in is None:
|
|
raise RuntimeError("MODEL input is required when checkpoint selection is NONE.")
|
|
base_model = model_in
|
|
embedded_clip, embedded_vae = _extract_embedded_components(base_model)
|
|
clip_candidate = clip_in or embedded_clip
|
|
vae_candidate = vae_in or embedded_vae
|
|
fallback_checkpoint = target_checkpoint or primary_checkpoint_name
|
|
if (clip_candidate is None or vae_candidate is None) and fallback_checkpoint is not None:
|
|
loaded_model, loaded_clip, loaded_vae = self._load_checkpoint(fallback_checkpoint)
|
|
clip_candidate = clip_candidate or loaded_clip
|
|
vae_candidate = vae_candidate or loaded_vae
|
|
missing: List[str] = []
|
|
if clip_candidate is None:
|
|
missing.append("CLIP")
|
|
if vae_candidate is None:
|
|
missing.append("VAE")
|
|
if missing:
|
|
detail = " and ".join(missing)
|
|
raise RuntimeError(
|
|
f"Unable to resolve {detail} from supplied model; wire explicit inputs or select a checkpoint."
|
|
)
|
|
base_clip = clip_candidate
|
|
base_vae = vae_candidate
|
|
clip_from_input = clip_in is not None
|
|
vae_from_input = vae_in is not None
|
|
else:
|
|
if target_checkpoint is None:
|
|
raise RuntimeError("No checkpoint could be resolved for execution plan.")
|
|
loaded_model, loaded_clip, loaded_vae = self._load_checkpoint(target_checkpoint)
|
|
base_model = loaded_model
|
|
base_clip = loaded_clip
|
|
base_vae = loaded_vae
|
|
if model_in is not None and primary_checkpoint_name is not None and target_checkpoint == primary_checkpoint_name:
|
|
base_model = model_in
|
|
if clip_in is not None and primary_checkpoint_name is not None and target_checkpoint == primary_checkpoint_name:
|
|
base_clip = clip_in
|
|
clip_from_input = True
|
|
if vae_in is not None and primary_checkpoint_name is not None and target_checkpoint == primary_checkpoint_name:
|
|
base_vae = vae_in
|
|
vae_from_input = True
|
|
|
|
if base_model is None:
|
|
raise RuntimeError("Model bundle missing model instance")
|
|
|
|
final_vae = base_vae
|
|
if final_vae is None:
|
|
raise RuntimeError("Model bundle missing VAE instance before overrides")
|
|
if not vae_from_input:
|
|
final_vae = self._load_vae_override(final_vae, vae_name)
|
|
if final_vae is None:
|
|
raise RuntimeError("Model bundle missing VAE instance")
|
|
|
|
final_clip = base_clip
|
|
if final_clip is None:
|
|
raise RuntimeError("Model bundle missing CLIP instance before overrides")
|
|
if not clip_from_input:
|
|
final_clip = self._load_clip_override(final_clip, clip_text_name, clip_vision_name)
|
|
if final_clip is None:
|
|
self.logger.warn("No CLIP available after overrides; using checkpoint CLIP as fallback")
|
|
final_clip = base_clip
|
|
if final_clip is None:
|
|
raise RuntimeError("Model bundle missing CLIP instance")
|
|
if clip_skip > 1:
|
|
final_clip = self._apply_clip_skip(final_clip, clip_skip)
|
|
|
|
bundle = (base_model, final_clip, final_vae)
|
|
active_bundle_key = target_key
|
|
active_bundle = bundle
|
|
return bundle
|
|
|
|
primary_model, primary_clip, primary_vae = ensure_bundle(
|
|
base_key,
|
|
primary_checkpoint_name,
|
|
prefer_inputs_for_base,
|
|
)
|
|
|
|
if bypass_engine:
|
|
self.logger.info("Bypass mode enabled; relaying inputs without sampling")
|
|
active_latent: Optional[Dict[str, torch.Tensor]] = None
|
|
images: Optional[torch.Tensor] = None
|
|
|
|
if latent_in is not None:
|
|
active_latent = clone_latent(latent_in)
|
|
images = self._decode(primary_vae, active_latent)
|
|
elif image_in is not None:
|
|
try:
|
|
image_payload = self._prepare_image_payload(image_in)
|
|
except Exception as exc:
|
|
raise RuntimeError(f"Unable to normalise image input during bypass: {exc}") from exc
|
|
image_tensor = image_payload["image"].to(dtype=torch.float32)
|
|
images = image_tensor
|
|
if self.vae_encode is not None:
|
|
try:
|
|
encoded = self.vae_encode.encode(primary_vae, image_tensor)
|
|
active_latent = clone_latent(encoded)
|
|
except Exception as exc:
|
|
self.logger.warn(
|
|
f"VAELatentEncode failed while preparing bypass latent; attempting direct encode ({exc})"
|
|
)
|
|
if active_latent is None:
|
|
if hasattr(primary_vae, "encode"):
|
|
pixels = image_tensor.permute(0, 3, 1, 2).contiguous()
|
|
try:
|
|
direct_encoded = primary_vae.encode(pixels)
|
|
except TypeError:
|
|
direct_encoded = primary_vae.encode(pixels)
|
|
if isinstance(direct_encoded, (dict, list, tuple)):
|
|
active_latent = clone_latent(direct_encoded)
|
|
else:
|
|
active_latent = clone_latent({"samples": direct_encoded})
|
|
else:
|
|
raise RuntimeError(
|
|
"Bypass mode requires a latent input or a VAE capable of encoding images"
|
|
)
|
|
else:
|
|
self.logger.info("No latent or image input supplied; creating empty latent for bypass output")
|
|
active_latent = self._prepare_latent(primary_vae, width, height, None, None)
|
|
images = self._decode(primary_vae, active_latent)
|
|
|
|
if active_latent is None or images is None:
|
|
raise RuntimeError("Bypass mode could not prepare outputs; ensure a latent or image input is connected")
|
|
|
|
images_out = images.detach().clone()
|
|
stacked = images_out
|
|
grid = images_out.clone()
|
|
|
|
legacy_conditioning = self._clone_conditioning(conditioning_in) if conditioning_in is not None else None
|
|
positive_source = (
|
|
conditioning_positive_in if conditioning_positive_in is not None else legacy_conditioning
|
|
)
|
|
negative_source = (
|
|
conditioning_negative_in if conditioning_negative_in is not None else legacy_conditioning
|
|
)
|
|
|
|
if positive_source is not None:
|
|
positive_out = self._clone_conditioning(positive_source)
|
|
else:
|
|
fallback_positive = positive_prompt or ""
|
|
positive_out = (
|
|
self._encode_prompt(primary_clip, fallback_positive) if primary_clip is not None else []
|
|
)
|
|
|
|
if negative_source is not None:
|
|
negative_out = self._clone_conditioning(negative_source)
|
|
else:
|
|
fallback_negative = negative_prompt if negative_prompt.strip() else ""
|
|
negative_out = (
|
|
self._encode_prompt(primary_clip, fallback_negative) if primary_clip is not None else []
|
|
)
|
|
|
|
latent_export = clone_latent(active_latent)
|
|
self.logger.info("Bypass mode complete")
|
|
return (
|
|
stacked,
|
|
grid,
|
|
primary_model,
|
|
primary_clip,
|
|
primary_vae,
|
|
latent_export,
|
|
positive_out,
|
|
negative_out,
|
|
)
|
|
|
|
x_descriptors = parse_axis_entries(x_axis_mode, x_axis_values)
|
|
y_descriptors = parse_axis_entries(y_axis_mode, y_axis_values)
|
|
axis_driver_style: Optional[Dict[str, Any]] = None
|
|
|
|
def _apply_axis_driver_override(slot_label: str, payload_text: Optional[str]) -> Optional[List[AxisDescriptor]]:
|
|
if not payload_text:
|
|
return None
|
|
try:
|
|
payload = json.loads(payload_text)
|
|
except Exception as exc:
|
|
self.logger.warn(f"Axis Driver payload for {slot_label} axis could not be parsed: {exc}")
|
|
return None
|
|
descriptors, style = _axis_driver_payload_to_descriptors(payload)
|
|
nonlocal axis_driver_style
|
|
if style and axis_driver_style is None:
|
|
axis_driver_style = style
|
|
if descriptors:
|
|
self.logger.info(
|
|
f"Axis Driver override applied to {slot_label} axis with {len(descriptors)} item(s)"
|
|
)
|
|
return descriptors
|
|
return None
|
|
|
|
override_x = _apply_axis_driver_override("X", axis_x)
|
|
if override_x is not None:
|
|
x_descriptors = override_x
|
|
override_y = _apply_axis_driver_override("Y", axis_y)
|
|
if override_y is not None:
|
|
y_descriptors = override_y
|
|
override_z = _apply_axis_driver_override("Z", axis_z)
|
|
if override_z is not None:
|
|
self.logger.info(
|
|
"Axis Driver Z axis data received; third-dimension plotting will be supported in a future release"
|
|
)
|
|
if legacy_summary and legacy_summary.strip():
|
|
self.logger.trace("Axis Driver summary:\n" + legacy_summary.strip())
|
|
if axis_driver_style:
|
|
style_snapshot = {
|
|
"label_layout": axis_driver_style.get("label_layout"),
|
|
"font_family": axis_driver_style.get("font_family"),
|
|
"font_size": axis_driver_style.get("font_size"),
|
|
}
|
|
self.logger.trace(
|
|
"Axis Driver style preferences captured: "
|
|
+ ", ".join(f"{key}={value}" for key, value in style_snapshot.items() if value is not None)
|
|
)
|
|
|
|
plans = build_generation_matrix(
|
|
base_checkpoint=primary_checkpoint_name,
|
|
x_descriptors=x_descriptors,
|
|
y_descriptors=y_descriptors,
|
|
)
|
|
if not plans:
|
|
raise RuntimeError("No execution plans could be constructed from the provided axis values")
|
|
self.logger.info(f"Generated {len(plans)} execution plan(s)")
|
|
grid_rows = max(1, len(y_descriptors))
|
|
grid_cols = max(1, len(x_descriptors))
|
|
|
|
# --- LANDMARK: Execution plan grouping ---
|
|
plan_targets: List[Dict[str, Any]] = []
|
|
plan_groups: Dict[str, List[int]] = {}
|
|
group_order: List[str] = []
|
|
|
|
for index, plan in enumerate(plans):
|
|
resolved_checkpoint = _normalise_checkpoint(plan.checkpoint_name)
|
|
if resolved_checkpoint is None:
|
|
resolved_checkpoint = primary_checkpoint_name
|
|
prefer_inputs = False
|
|
if model_in is not None:
|
|
if resolved_checkpoint is None:
|
|
prefer_inputs = True
|
|
elif base_model_tag and resolved_checkpoint == base_model_tag:
|
|
prefer_inputs = True
|
|
elif resolved_checkpoint == primary_checkpoint_name:
|
|
prefer_inputs = True
|
|
key = "__input__" if prefer_inputs else (resolved_checkpoint or "__anonymous__")
|
|
plan_targets.append(
|
|
{
|
|
"checkpoint": resolved_checkpoint,
|
|
"prefer_inputs": prefer_inputs,
|
|
"key": key,
|
|
}
|
|
)
|
|
if key not in plan_groups:
|
|
plan_groups[key] = []
|
|
group_order.append(key)
|
|
plan_groups[key].append(index)
|
|
|
|
if "__anonymous__" in plan_groups:
|
|
raise RuntimeError(
|
|
"No checkpoint available for one or more plans; supply a MODEL input or select a checkpoint."
|
|
)
|
|
|
|
base_latent = self._prepare_latent(
|
|
primary_vae,
|
|
width,
|
|
height,
|
|
latent_in,
|
|
image_in,
|
|
)
|
|
|
|
grid_slots: List[Optional[torch.Tensor]] = [None] * len(plans)
|
|
stack_slots: List[Optional[torch.Tensor]] = [None] * len(plans)
|
|
summary_slots: List[Optional[Dict[str, Any]]] = [None] * len(plans)
|
|
|
|
final_index = len(plans) - 1
|
|
final_model = primary_model
|
|
final_clip = primary_clip
|
|
final_vae = primary_vae
|
|
final_latent: Optional[Dict[str, torch.Tensor]] = None
|
|
final_positive: Optional[Any] = None
|
|
final_negative: Optional[Any] = None
|
|
|
|
legacy_conditioning = self._clone_conditioning(conditioning_in) if conditioning_in is not None else None
|
|
positive_override_source = (
|
|
conditioning_positive_in if conditioning_positive_in is not None else legacy_conditioning
|
|
)
|
|
negative_override_source = (
|
|
conditioning_negative_in if conditioning_negative_in is not None else legacy_conditioning
|
|
)
|
|
|
|
for group_key in group_order:
|
|
indices = plan_groups[group_key]
|
|
meta_first = plan_targets[indices[0]]
|
|
active_model, active_clip, active_vae = ensure_bundle(
|
|
meta_first["key"],
|
|
meta_first["checkpoint"],
|
|
meta_first["prefer_inputs"],
|
|
)
|
|
for plan_index in indices:
|
|
plan = plans[plan_index]
|
|
meta = plan_targets[plan_index]
|
|
if meta["key"] != group_key:
|
|
active_model, active_clip, active_vae = ensure_bundle(
|
|
meta["key"],
|
|
meta["checkpoint"],
|
|
meta["prefer_inputs"],
|
|
)
|
|
self.logger.info(f"Executing plan {plan_index + 1}/{len(plans)} :: {plan.label}")
|
|
if plan_index == final_index:
|
|
final_model = active_model
|
|
final_clip = active_clip
|
|
final_vae = active_vae
|
|
cfg_current = plan.overrides.get("cfg", cfg)
|
|
steps_current = plan.overrides.get("steps", steps)
|
|
denoise_current = plan.overrides.get("denoise", denoise)
|
|
sampler_current = plan.overrides.get("sampler", sampler_name)
|
|
scheduler_current = plan.overrides.get("scheduler", scheduler_name)
|
|
seed_current = plan.overrides.get("seed", seed)
|
|
patched_model, patched_clip = self._apply_loras(active_model, active_clip, plan.loras)
|
|
if positive_override_source is not None:
|
|
positive = self._clone_conditioning(positive_override_source)
|
|
else:
|
|
effective_prompt = positive_prompt
|
|
if plan.prompt_suffix:
|
|
effective_prompt = f"{positive_prompt}\n{plan.prompt_suffix}".strip()
|
|
positive = self._encode_prompt(patched_clip, effective_prompt)
|
|
|
|
if negative_override_source is not None:
|
|
negative = self._clone_conditioning(negative_override_source)
|
|
else:
|
|
baseline_negative = negative_prompt if negative_prompt.strip() else ""
|
|
if cfg_current > 1.0 and baseline_negative:
|
|
negative = self._encode_prompt(patched_clip, baseline_negative)
|
|
else:
|
|
negative = self._encode_prompt(patched_clip, "")
|
|
|
|
use_base_latent = meta["prefer_inputs"] or meta["checkpoint"] == primary_checkpoint_name
|
|
if use_base_latent:
|
|
latent_copy = clone_latent(base_latent)
|
|
else:
|
|
latent_seed = self._prepare_latent(active_vae, width, height, latent_in, image_in)
|
|
latent_copy = clone_latent(latent_seed)
|
|
|
|
sample_tensor = latent_copy.get("samples")
|
|
if isinstance(sample_tensor, torch.Tensor):
|
|
sample_device = sample_tensor.device
|
|
else:
|
|
sample_device = torch.device("cpu")
|
|
target_device = self._resolve_device(patched_model, sample_device)
|
|
try:
|
|
patched_model = self._obtain_model_proxy(patched_model, target_device)
|
|
except Exception as exc:
|
|
self.logger.warn(f"Device alignment proxy unavailable; continuing without shim ({exc})")
|
|
|
|
latent_copy = cast(
|
|
Dict[str, torch.Tensor],
|
|
self._enforce_payload_device(
|
|
"Latent payload",
|
|
latent_copy,
|
|
target_device,
|
|
self._latent_to_device,
|
|
),
|
|
)
|
|
positive = self._enforce_payload_device(
|
|
"Positive conditioning",
|
|
positive,
|
|
target_device,
|
|
self._conditioning_to_device,
|
|
)
|
|
negative = self._enforce_payload_device(
|
|
"Negative conditioning",
|
|
negative,
|
|
target_device,
|
|
self._conditioning_to_device,
|
|
)
|
|
|
|
result_latent = self._run_sampler(
|
|
model=patched_model,
|
|
vae=active_vae,
|
|
positive=positive,
|
|
negative=negative,
|
|
latent=latent_copy,
|
|
seed=seed_current,
|
|
steps=steps_current,
|
|
cfg=cfg_current,
|
|
denoise=denoise_current,
|
|
sampler_name=sampler_current,
|
|
scheduler_name=scheduler_current,
|
|
)
|
|
try:
|
|
decoded_images = self._decode(active_vae, result_latent)
|
|
except Exception as exc:
|
|
raise RuntimeError(f"Failed to decode sampler output for plan {plan.label}: {exc}") from exc
|
|
|
|
decoded_clone = decoded_images.detach().clone()
|
|
decoded_cpu = decoded_clone.to(device="cpu")
|
|
grid_slots[plan_index] = decoded_cpu
|
|
stack_slots[plan_index] = decoded_cpu
|
|
latent_samples = result_latent.get("samples") if isinstance(result_latent, dict) else None
|
|
plan_summary: Dict[str, Any] = {
|
|
"label": plan.label,
|
|
"checkpoint": meta["checkpoint"] or primary_checkpoint_name or "<input>",
|
|
"seed": seed_current,
|
|
"steps": steps_current,
|
|
"cfg": cfg_current,
|
|
"denoise": denoise_current,
|
|
"sampler": sampler_current,
|
|
"scheduler": scheduler_current,
|
|
"decoded_shape": tuple(decoded_cpu.shape),
|
|
}
|
|
if isinstance(latent_samples, torch.Tensor):
|
|
latent_samples_tensor = cast(torch.Tensor, latent_samples)
|
|
plan_summary["latent_shape"] = tuple(latent_samples_tensor.shape)
|
|
plan_summary["pixel_range"] = (
|
|
float(decoded_cpu.min().item()),
|
|
float(decoded_cpu.max().item()),
|
|
)
|
|
summary_slots[plan_index] = plan_summary
|
|
|
|
if plan_index == final_index:
|
|
final_latent = result_latent
|
|
final_positive = positive
|
|
final_negative = negative
|
|
|
|
if torch.cuda.is_available(): # pragma: no cover - device-specific
|
|
torch.cuda.empty_cache()
|
|
|
|
release_active_bundle()
|
|
|
|
plan_summaries: List[Dict[str, Any]] = []
|
|
grid_images: List[torch.Tensor] = []
|
|
image_stack: List[torch.Tensor] = []
|
|
for idx in range(len(plans)):
|
|
grid_tensor = grid_slots[idx]
|
|
stack_tensor = stack_slots[idx]
|
|
summary_payload = summary_slots[idx]
|
|
if grid_tensor is None or stack_tensor is None or summary_payload is None:
|
|
raise RuntimeError(f"Execution plan {plans[idx].label} did not produce an image result")
|
|
grid_images.append(grid_tensor)
|
|
image_stack.append(stack_tensor)
|
|
plan_summaries.append(summary_payload)
|
|
|
|
grid = compose_image_grid(grid_images, grid_rows, grid_cols)
|
|
grid = annotate_grid_image(
|
|
grid,
|
|
plans,
|
|
grid_rows,
|
|
grid_cols,
|
|
self.logger,
|
|
style=axis_driver_style,
|
|
x_axis=x_descriptors,
|
|
y_axis=y_descriptors,
|
|
)
|
|
stacked = torch.cat(image_stack, dim=0) if image_stack else grid
|
|
try:
|
|
execution_report = self._build_report(plans, plan_summaries, (grid_rows, grid_cols))
|
|
self.logger.trace(execution_report)
|
|
except Exception as exc:
|
|
self.logger.debug(f"Generation report unavailable: {exc}")
|
|
self.logger.info("Engine pipeline complete")
|
|
latent_export = clone_latent(final_latent or base_latent)
|
|
if final_positive is None:
|
|
fallback_prompt = positive_prompt or ""
|
|
final_positive = self._encode_prompt(final_clip, fallback_prompt) if final_clip is not None else []
|
|
if final_negative is None:
|
|
fallback_negative_prompt = negative_prompt if negative_prompt.strip() else ""
|
|
if final_clip is not None:
|
|
final_negative = self._encode_prompt(final_clip, fallback_negative_prompt)
|
|
else:
|
|
final_negative = []
|
|
positive_out = self._clone_conditioning(final_positive)
|
|
negative_out = self._clone_conditioning(final_negative)
|
|
return (
|
|
stacked,
|
|
grid,
|
|
final_model,
|
|
final_clip,
|
|
final_vae,
|
|
latent_export,
|
|
positive_out,
|
|
negative_out,
|
|
)
|
|
|
|
|
|
class h4_Varianator:
|
|
"""Standalone variation generator that riffs on a supplied latent."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Any]: # noqa: N802
|
|
sampler_default = SAMPLER_CHOICES[0] if SAMPLER_CHOICES else "euler"
|
|
scheduler_default = SCHEDULER_CHOICES[0] if SCHEDULER_CHOICES else "normal"
|
|
profile_keys = list(VARIANATOR_PROFILE_RANGES.keys())
|
|
if not profile_keys:
|
|
profile_keys = ["moderate"]
|
|
profile_choices = tuple(profile_keys)
|
|
profile_default = profile_keys[1] if len(profile_keys) > 1 else profile_keys[0]
|
|
return {
|
|
"required": {
|
|
"variation_count": (
|
|
"INT",
|
|
{
|
|
"default": 4,
|
|
"min": 1,
|
|
"max": VARIANATOR_MAX_VARIATIONS,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("variation_count"),
|
|
},
|
|
),
|
|
"variation_profile": (
|
|
profile_choices,
|
|
{
|
|
"default": profile_default,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("variation_profile"),
|
|
},
|
|
),
|
|
"seed_mode": (
|
|
["fixed", "increment", "random"],
|
|
{
|
|
"default": "increment",
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("seed_mode"),
|
|
},
|
|
),
|
|
"base_seed": (
|
|
"INT",
|
|
{
|
|
"default": 123456789,
|
|
"min": 0,
|
|
"max": VARIANATOR_SEED_LIMIT,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("base_seed"),
|
|
},
|
|
),
|
|
"sampler_name": (
|
|
SAMPLER_CHOICES,
|
|
{
|
|
"default": sampler_default,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("sampler_name"),
|
|
},
|
|
),
|
|
"scheduler_name": (
|
|
SCHEDULER_CHOICES,
|
|
{
|
|
"default": scheduler_default,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("scheduler_name"),
|
|
},
|
|
),
|
|
"steps": (
|
|
"INT",
|
|
{
|
|
"default": 18,
|
|
"min": 1,
|
|
"max": 150,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("steps"),
|
|
},
|
|
),
|
|
"cfg": (
|
|
"FLOAT",
|
|
{
|
|
"default": 7.0,
|
|
"min": 1.0,
|
|
"max": 30.0,
|
|
"step": 0.1,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("cfg"),
|
|
},
|
|
),
|
|
"go_ultra": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": False,
|
|
"label": " GO PLUS ULTRA?! ",
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("go_ultra"),
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"vae": ("VAE",),
|
|
"latent_in": ("LATENT", {"forceInput": True}),
|
|
"positive_in": ("CONDITIONING",),
|
|
"negative_in": ("CONDITIONING",),
|
|
"style_positive_in": ("CONDITIONING",),
|
|
"style_negative_in": ("CONDITIONING",),
|
|
"prompt_jitter_enabled": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": False,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("prompt_jitter_enabled"),
|
|
},
|
|
),
|
|
"prompt_jitter_strength": (
|
|
"FLOAT",
|
|
{
|
|
"default": 0.15,
|
|
"min": 0.0,
|
|
"max": 1.0,
|
|
"step": 0.05,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("prompt_jitter_strength"),
|
|
},
|
|
),
|
|
"prompt_jitter_tokens": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("prompt_jitter_tokens"),
|
|
},
|
|
),
|
|
"style_mix_enabled": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": False,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("style_mix_enabled"),
|
|
},
|
|
),
|
|
"style_mix_ratio": (
|
|
"FLOAT",
|
|
{
|
|
"default": 0.35,
|
|
"min": 0.0,
|
|
"max": 1.0,
|
|
"step": 0.05,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("style_mix_ratio"),
|
|
},
|
|
),
|
|
"base_positive_prompt": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("base_positive_prompt"),
|
|
},
|
|
),
|
|
"base_negative_prompt": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("base_negative_prompt"),
|
|
},
|
|
),
|
|
"style_mix_prompt": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("style_mix_prompt"),
|
|
},
|
|
),
|
|
"style_mix_negative_prompt": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("style_mix_negative_prompt"),
|
|
},
|
|
),
|
|
"ultra_json_log": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": True,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("ultra_json_log"),
|
|
},
|
|
),
|
|
"ultra_cache_artifacts": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": True,
|
|
"tooltip": VARIANATOR_WIDGET_TOOLTIPS.get("ultra_cache_artifacts"),
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "LATENT", "STRING")
|
|
RETURN_NAMES = ("variations", "latent_batch", "summary")
|
|
FUNCTION = "generate"
|
|
CATEGORY = "h4 Toolkit/Generation"
|
|
|
|
def __init__(self) -> None:
|
|
self.logger = ToolkitLogger("h4_Varianator")
|
|
self._engine = h4_PlotXY()
|
|
self._engine.logger = self.logger
|
|
|
|
@staticmethod
|
|
def _normalise_count(value: Any) -> int:
|
|
try:
|
|
count = int(value)
|
|
except (TypeError, ValueError):
|
|
count = 1
|
|
return max(1, min(count, VARIANATOR_MAX_VARIATIONS))
|
|
|
|
@staticmethod
|
|
def _coerce_seed(value: Any) -> int:
|
|
try:
|
|
integer = int(value)
|
|
except (TypeError, ValueError):
|
|
integer = 0
|
|
return max(0, min(integer, VARIANATOR_SEED_LIMIT))
|
|
|
|
@staticmethod
|
|
def _normalise_seed_mode(mode: Optional[str]) -> str:
|
|
candidate = str(mode or "").strip().lower()
|
|
return candidate if candidate in {"fixed", "increment", "random"} else "fixed"
|
|
|
|
@staticmethod
|
|
def _resolve_profile(profile: Optional[str]) -> Tuple[str, Tuple[float, float]]:
|
|
key = str(profile or "").strip().lower()
|
|
if key not in VARIANATOR_PROFILE_RANGES:
|
|
key = "moderate"
|
|
low, high = VARIANATOR_PROFILE_RANGES.get(key, (0.4, 0.5))
|
|
if low > high:
|
|
low, high = high, low
|
|
low = max(0.0, min(low, 1.0))
|
|
high = max(0.0, min(high, 1.0))
|
|
return key, (low, high)
|
|
|
|
def _parse_jitter_tokens(self, raw: str, default_strength: float) -> List[Dict[str, Any]]:
|
|
entries: List[Dict[str, Any]] = []
|
|
if not raw:
|
|
return entries
|
|
default_low = max(0.0, 1.0 - float(default_strength))
|
|
default_high = max(default_low, 1.0 + float(default_strength))
|
|
for line in raw.splitlines():
|
|
token = line.strip()
|
|
if not token:
|
|
continue
|
|
pieces = [segment.strip() for segment in token.split("|") if segment.strip()]
|
|
if not pieces:
|
|
continue
|
|
name = pieces[0]
|
|
if not name:
|
|
continue
|
|
try:
|
|
lower = float(pieces[1]) if len(pieces) > 1 else default_low
|
|
upper = float(pieces[2]) if len(pieces) > 2 else default_high
|
|
except (TypeError, ValueError):
|
|
lower, upper = default_low, default_high
|
|
if upper < lower:
|
|
lower, upper = upper, lower
|
|
lower = max(0.0, min(lower, 3.0))
|
|
upper = max(0.0, min(upper, 3.5))
|
|
entries.append({"token": name, "min": lower, "max": upper})
|
|
return entries
|
|
|
|
def _apply_prompt_jitter(
|
|
self,
|
|
base_prompt: str,
|
|
entries: List[Dict[str, Any]],
|
|
rng: random.Random,
|
|
) -> Tuple[str, List[Dict[str, Any]]]:
|
|
prompt = base_prompt.strip() if base_prompt else ""
|
|
adjustments: List[Dict[str, Any]] = []
|
|
for entry in entries:
|
|
low = float(entry["min"])
|
|
high = float(entry["max"])
|
|
if high < low:
|
|
low, high = high, low
|
|
weight = rng.uniform(low, high)
|
|
adjustments.append({"token": entry["token"], "weight": weight})
|
|
if adjustments:
|
|
jitter_line = " ".join(f"({item['token']}:{item['weight']:.3f})" for item in adjustments)
|
|
prompt = f"{prompt}\n{jitter_line}" if prompt else jitter_line
|
|
return prompt, adjustments
|
|
|
|
def _blend_conditioning(self, base: Any, style: Any, ratio: float) -> Any:
|
|
ratio = max(0.0, min(float(ratio), 1.0))
|
|
if style is None or ratio <= 0.0:
|
|
return self._engine._clone_conditioning(base)
|
|
if base is None:
|
|
return None
|
|
if torch.is_tensor(base) and torch.is_tensor(style):
|
|
return base * (1.0 - ratio) + style * ratio
|
|
if isinstance(base, dict) and isinstance(style, dict):
|
|
blended: Dict[str, Any] = {}
|
|
for key in set(base.keys()) | set(style.keys()):
|
|
if key in base and key in style:
|
|
blended[key] = self._blend_conditioning(base[key], style[key], ratio)
|
|
elif key in base:
|
|
blended[key] = self._engine._clone_conditioning(base[key])
|
|
else:
|
|
blended[key] = self._engine._clone_conditioning(style[key])
|
|
return blended
|
|
if isinstance(base, (list, tuple)) and isinstance(style, (list, tuple)):
|
|
limit = min(len(base), len(style))
|
|
blended_items = [self._blend_conditioning(base[idx], style[idx], ratio) for idx in range(limit)]
|
|
if isinstance(base, tuple):
|
|
tail = [self._engine._clone_conditioning(item) for item in base[limit:]]
|
|
return tuple(blended_items + tail)
|
|
tail_list = [self._engine._clone_conditioning(item) for item in base[limit:]]
|
|
return blended_items + tail_list
|
|
return self._engine._clone_conditioning(base)
|
|
|
|
def _ensure_ultra_directory(self) -> Optional[str]:
|
|
base_dir: Optional[str] = None
|
|
get_temp_dir = getattr(folder_paths, "get_temp_directory", None)
|
|
if callable(get_temp_dir):
|
|
try:
|
|
candidate = get_temp_dir()
|
|
if isinstance(candidate, str):
|
|
base_dir = candidate
|
|
except Exception:
|
|
base_dir = None
|
|
if not base_dir:
|
|
get_output_dir = getattr(folder_paths, "get_output_directory", None)
|
|
if callable(get_output_dir):
|
|
try:
|
|
candidate = get_output_dir()
|
|
if isinstance(candidate, str):
|
|
base_dir = candidate
|
|
except Exception:
|
|
base_dir = None
|
|
if not base_dir:
|
|
return None
|
|
target = os.path.join(base_dir, "h4_varianator_ultra")
|
|
try:
|
|
os.makedirs(target, exist_ok=True)
|
|
except Exception as exc:
|
|
self.logger.warn(f"Unable to create Go_Plus_ULTRA artefact directory: {exc}")
|
|
return None
|
|
return target
|
|
|
|
def _tensor_to_image(self, tensor: torch.Tensor) -> Optional[Any]:
|
|
if Image is None or tensor is None:
|
|
return None
|
|
try:
|
|
normalised = self._engine._normalise_image_tensor(tensor)
|
|
sample = normalised[0].detach().to(device="cpu", dtype=torch.float32).clamp(0.0, 1.0)
|
|
array = sample.numpy()
|
|
if array.ndim == 2:
|
|
array = np.expand_dims(array, axis=-1)
|
|
channels = array.shape[-1]
|
|
if channels == 1:
|
|
array = np.repeat(array, 3, axis=-1)
|
|
elif channels == 2:
|
|
pad = np.zeros((*array.shape[:-1], 1), dtype=array.dtype)
|
|
array = np.concatenate([array, pad], axis=-1)
|
|
array = np.clip(array * 255.0, 0, 255).astype(np.uint8)
|
|
return Image.fromarray(array)
|
|
except Exception as exc:
|
|
self.logger.warn(f"Failed to convert tensor to preview image: {exc}")
|
|
return None
|
|
|
|
def _write_ultra_json(self, payload: Dict[str, Any], persist: bool) -> Tuple[Optional[str], str]:
|
|
text = json.dumps(payload, indent=2, ensure_ascii=False)
|
|
if not persist:
|
|
return None, text
|
|
directory = self._ensure_ultra_directory()
|
|
if directory is None:
|
|
return None, text
|
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f")
|
|
path = os.path.join(directory, f"varianator_{timestamp}.json")
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(text)
|
|
return path, text
|
|
except Exception as exc:
|
|
self.logger.warn(f"Unable to write Go_Plus_ULTRA log: {exc}")
|
|
return None, text
|
|
|
|
def generate(
|
|
self,
|
|
variation_count: int,
|
|
variation_profile: str,
|
|
seed_mode: str,
|
|
base_seed: int,
|
|
sampler_name: str,
|
|
scheduler_name: str,
|
|
steps: int,
|
|
cfg: float,
|
|
go_ultra: bool,
|
|
model: Optional[Any] = None,
|
|
clip: Optional[Any] = None,
|
|
vae: Optional[Any] = None,
|
|
latent_in: Optional[Dict[str, torch.Tensor]] = None,
|
|
positive_in: Optional[Any] = None,
|
|
negative_in: Optional[Any] = None,
|
|
style_positive_in: Optional[Any] = None,
|
|
style_negative_in: Optional[Any] = None,
|
|
prompt_jitter_enabled: bool = False,
|
|
prompt_jitter_strength: float = 0.15,
|
|
prompt_jitter_tokens: str = "",
|
|
style_mix_enabled: bool = False,
|
|
style_mix_ratio: float = 0.35,
|
|
base_positive_prompt: str = "",
|
|
base_negative_prompt: str = "",
|
|
style_mix_prompt: str = "",
|
|
style_mix_negative_prompt: str = "",
|
|
ultra_json_log: bool = True,
|
|
ultra_cache_artifacts: bool = True,
|
|
) -> Tuple[torch.Tensor, Dict[str, torch.Tensor], str]:
|
|
self.logger.info("Varianator engaged")
|
|
count = self._normalise_count(variation_count)
|
|
profile_key, (profile_min, profile_max) = self._resolve_profile(variation_profile)
|
|
mode = self._normalise_seed_mode(seed_mode)
|
|
anchor_seed = self._coerce_seed(base_seed)
|
|
steps = max(1, int(steps))
|
|
cfg = float(cfg)
|
|
if model is None or vae is None:
|
|
raise RuntimeError("Varianator requires MODEL and VAE inputs.")
|
|
if latent_in is None:
|
|
raise RuntimeError("Varianator requires a latent input to remix.")
|
|
|
|
sampler_label = sampler_name or (SAMPLER_CHOICES[0] if SAMPLER_CHOICES else "euler")
|
|
scheduler_label = scheduler_name or (SCHEDULER_CHOICES[0] if SCHEDULER_CHOICES else "normal")
|
|
|
|
jitter_entries = self._parse_jitter_tokens(prompt_jitter_tokens, float(prompt_jitter_strength))
|
|
style_mix_ratio = max(0.0, min(float(style_mix_ratio), 1.0))
|
|
|
|
seed_rng = random.Random(anchor_seed)
|
|
denoise_rng = random.Random(anchor_seed ^ 0xBADF00D)
|
|
|
|
style_positive_source: Optional[Any] = None
|
|
style_negative_source: Optional[Any] = None
|
|
if style_mix_enabled:
|
|
if style_positive_in is not None:
|
|
style_positive_source = self._engine._clone_conditioning(style_positive_in)
|
|
elif style_mix_prompt.strip() and clip is not None:
|
|
style_positive_source = self._engine._encode_prompt(clip, style_mix_prompt.strip())
|
|
if style_negative_in is not None:
|
|
style_negative_source = self._engine._clone_conditioning(style_negative_in)
|
|
elif style_mix_negative_prompt.strip() and clip is not None:
|
|
style_negative_source = self._engine._encode_prompt(clip, style_mix_negative_prompt.strip())
|
|
if style_mix_enabled and style_positive_source is None and style_negative_source is None:
|
|
self.logger.warn("Style mix enabled but no style source provided; disabling mix")
|
|
style_mix_enabled = False
|
|
|
|
variations: List[torch.Tensor] = []
|
|
latent_batches: List[torch.Tensor] = []
|
|
metadata: List[Dict[str, Any]] = []
|
|
last_latent_dict: Optional[Dict[str, torch.Tensor]] = None
|
|
|
|
for index in range(count):
|
|
if mode == "fixed":
|
|
seed_value = anchor_seed
|
|
elif mode == "increment":
|
|
seed_value = self._coerce_seed(anchor_seed + index)
|
|
else:
|
|
seed_value = seed_rng.randint(0, VARIANATOR_SEED_LIMIT)
|
|
denoise_value = float(denoise_rng.uniform(profile_min, profile_max))
|
|
|
|
prompt_text_used: Optional[str] = None
|
|
jitter_applied: List[Dict[str, Any]] = []
|
|
|
|
if positive_in is not None and not (prompt_jitter_enabled or style_mix_enabled):
|
|
positive = self._engine._clone_conditioning(positive_in)
|
|
else:
|
|
if clip is None:
|
|
raise RuntimeError(
|
|
"Varianator needs a CLIP input when prompt jitter or style mixing is active or no positive conditioning is wired."
|
|
)
|
|
prompt_seed = (anchor_seed + index * 7919) & 0xFFFFFFFFFFFF
|
|
jitter_rng = random.Random(prompt_seed)
|
|
prompt_text = base_positive_prompt or ""
|
|
if prompt_jitter_enabled and jitter_entries:
|
|
prompt_text, jitter_applied = self._apply_prompt_jitter(prompt_text, jitter_entries, jitter_rng)
|
|
prompt_text_used = prompt_text
|
|
positive = self._engine._encode_prompt(clip, prompt_text)
|
|
|
|
if style_mix_enabled and style_positive_source is not None:
|
|
positive = self._blend_conditioning(positive, style_positive_source, style_mix_ratio)
|
|
|
|
if negative_in is not None:
|
|
negative = self._engine._clone_conditioning(negative_in)
|
|
else:
|
|
if clip is None:
|
|
negative = []
|
|
else:
|
|
negative_prompt = base_negative_prompt or ""
|
|
negative = self._engine._encode_prompt(clip, negative_prompt)
|
|
|
|
if style_mix_enabled and style_negative_source is not None:
|
|
negative = self._blend_conditioning(negative, style_negative_source, style_mix_ratio)
|
|
|
|
latent_seed = clone_latent(latent_in)
|
|
result_latent = self._engine._run_sampler(
|
|
model=model,
|
|
vae=vae,
|
|
positive=positive,
|
|
negative=negative,
|
|
latent=latent_seed,
|
|
seed=seed_value,
|
|
steps=steps,
|
|
cfg=cfg,
|
|
denoise=max(0.0, min(denoise_value, 1.0)),
|
|
sampler_name=sampler_label,
|
|
scheduler_name=scheduler_label,
|
|
)
|
|
last_latent_dict = result_latent if isinstance(result_latent, dict) else None
|
|
decoded = self._engine._decode(vae, result_latent)
|
|
decoded_cpu = decoded.detach().clone().to(device="cpu")
|
|
variations.append(decoded_cpu)
|
|
|
|
latent_tensor = None
|
|
if isinstance(result_latent, dict):
|
|
latent_tensor = result_latent.get("samples")
|
|
elif isinstance(result_latent, torch.Tensor):
|
|
latent_tensor = result_latent
|
|
if isinstance(latent_tensor, torch.Tensor):
|
|
tensor_value = cast(torch.Tensor, latent_tensor)
|
|
latent_cpu = tensor_value.detach().clone().to(device="cpu")
|
|
latent_batches.append(latent_cpu)
|
|
|
|
preview_path: Optional[str] = None
|
|
if go_ultra and ultra_cache_artifacts:
|
|
image = self._tensor_to_image(decoded_cpu)
|
|
directory = self._ensure_ultra_directory()
|
|
if image is not None and directory is not None:
|
|
filename = f"variation_{index + 1:02d}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S_%f')}.png"
|
|
target = os.path.join(directory, filename)
|
|
try:
|
|
image.save(target)
|
|
preview_path = target
|
|
except Exception as exc:
|
|
self.logger.warn(f"Failed to persist variation preview: {exc}")
|
|
|
|
entry = {
|
|
"index": index,
|
|
"seed": int(seed_value),
|
|
"denoise": float(round(denoise_value, 4)),
|
|
"profile": profile_key,
|
|
"prompt": prompt_text_used,
|
|
"jitter": jitter_applied,
|
|
"style_mix_ratio": style_mix_ratio if style_mix_enabled else 0.0,
|
|
"image_path": preview_path,
|
|
"image_shape": tuple(decoded_cpu.shape),
|
|
}
|
|
if latent_batches:
|
|
entry["latent_shape"] = tuple(latent_batches[-1].shape)
|
|
metadata.append(entry)
|
|
|
|
if torch.cuda.is_available(): # pragma: no cover - device-specific cleanup
|
|
torch.cuda.empty_cache()
|
|
|
|
model_management.soft_empty_cache()
|
|
|
|
if not variations:
|
|
raise RuntimeError("No variations were generated")
|
|
|
|
images_out = torch.cat(variations, dim=0).to(dtype=torch.float32)
|
|
|
|
if latent_batches:
|
|
latent_out: Dict[str, torch.Tensor] = {"samples": torch.cat(latent_batches, dim=0)}
|
|
elif last_latent_dict and isinstance(last_latent_dict.get("samples"), torch.Tensor):
|
|
latent_out = {"samples": last_latent_dict["samples"].detach().clone().to(device="cpu")}
|
|
else:
|
|
latent_out = clone_latent(latent_in)
|
|
samples = latent_out.get("samples")
|
|
if isinstance(samples, torch.Tensor):
|
|
samples_value = cast(torch.Tensor, samples)
|
|
samples_cpu = samples_value.detach().clone().to(device="cpu")
|
|
latent_out["samples"] = samples_cpu
|
|
|
|
summary_lines = [
|
|
f"Varianator generated {len(metadata)} variation(s) using profile '{profile_key}'.",
|
|
f"Sampler={sampler_label} Scheduler={scheduler_label} Steps={steps} CFG={cfg:.2f}",
|
|
]
|
|
for entry in metadata:
|
|
line = f"[{entry['index'] + 1:02d}] seed={entry['seed']} denoise={entry['denoise']:.3f}"
|
|
if entry.get("image_path"):
|
|
line += f" preview={entry['image_path']}"
|
|
summary_lines.append(line)
|
|
if entry.get("jitter"):
|
|
jitter_desc = ", ".join(
|
|
f"{item['token']}:{item['weight']:.2f}"
|
|
for item in entry["jitter"]
|
|
)
|
|
summary_lines.append(f" jitter -> {jitter_desc}")
|
|
|
|
ultra_path: Optional[str] = None
|
|
ultra_payload_text: Optional[str] = None
|
|
if go_ultra:
|
|
ultra_payload = {
|
|
"node": "h4_Varianator",
|
|
"version": VARIANATOR_VERSION,
|
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
"profile": profile_key,
|
|
"seed_mode": mode,
|
|
"seed_anchor": anchor_seed,
|
|
"sampler": sampler_label,
|
|
"scheduler": scheduler_label,
|
|
"steps": steps,
|
|
"cfg": cfg,
|
|
"variations": metadata,
|
|
}
|
|
if ultra_json_log:
|
|
ultra_path, ultra_payload_text = self._write_ultra_json(ultra_payload, ultra_cache_artifacts)
|
|
if ultra_path:
|
|
self.logger.info(f"Go_Plus_ULTRA :: JSON log saved -> {ultra_path}")
|
|
elif not ultra_cache_artifacts:
|
|
self.logger.info("Go_Plus_ULTRA :: JSON log generated (not persisted)")
|
|
else:
|
|
self.logger.warn("Go_Plus_ULTRA :: Failed to persist JSON log")
|
|
else:
|
|
ultra_payload_text = json.dumps(ultra_payload, indent=2, ensure_ascii=False)
|
|
|
|
if ultra_path:
|
|
summary_lines.append(f"Ultra log saved -> {ultra_path}")
|
|
elif ultra_json_log and go_ultra and not ultra_cache_artifacts:
|
|
summary_lines.append("Ultra log generated (not persisted)")
|
|
|
|
summary_text = "\n".join(summary_lines)
|
|
self.logger.info(summary_text)
|
|
return images_out, latent_out, summary_text
|
|
|
|
|
|
class h4_DebugATron3000:
|
|
"""Adaptive router that mirrors signals per data type while logging everything."""
|
|
|
|
SLOT_DEFINITIONS: Tuple[Tuple[str, str, str], ...] = DEBUG_SLOT_DEFINITIONS
|
|
SLOT_TOOLTIPS: Dict[str, str] = DEBUG_SLOT_TOOLTIPS
|
|
VAE_MODEL_FALLBACKS: Dict[str, str] = {"vae_in": "model_in"}
|
|
BRANCH_DISPLAY_NAMES: Dict[str, str] = {}
|
|
LEGACY_INPUT_ALIASES: Dict[str, str] = {
|
|
"model": "model_in",
|
|
"clip": "clip_in",
|
|
"clip_vision": "clip_vision_in",
|
|
"vae": "vae_in",
|
|
"conditioning": "conditioning_in",
|
|
"positive": "conditioning_positive_in",
|
|
"negative": "conditioning_negative_in",
|
|
"latent": "latent_in",
|
|
"image": "image_in",
|
|
"mask": "mask_in",
|
|
}
|
|
|
|
_VAE_ATTR_CANDIDATES: Tuple[str, ...] = (
|
|
"vae",
|
|
"first_stage_model",
|
|
"autoencoder",
|
|
"model_vae",
|
|
)
|
|
|
|
_MODEL_ATTR_CANDIDATES: Tuple[str, ...] = ("model", "inner_model", "diffusion_model")
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Any]: # noqa: N802
|
|
optional_inputs: Dict[str, Tuple[str, Dict[str, Any]]] = {}
|
|
for slot_name, display_name, slot_type in cls.SLOT_DEFINITIONS:
|
|
tooltip = cls.SLOT_TOOLTIPS.get(slot_name)
|
|
extra: Dict[str, Any] = {"tooltip": tooltip} if tooltip else {}
|
|
extra.setdefault("default", None)
|
|
extra.setdefault("label", display_name)
|
|
optional_inputs[slot_name] = (slot_type, extra)
|
|
optional_inputs["go_ultra"] = (
|
|
"BOOLEAN",
|
|
{
|
|
"default": False,
|
|
"label": " GO PLUS ULTRA?! ",
|
|
"tooltip": "Flip the switch marked GO PLUS ULTRA?! to open deep-dive logging, previews, and artefact capture.",
|
|
},
|
|
)
|
|
for control in ULTRA_CONTROL_DEFINITIONS:
|
|
options = {
|
|
"default": control["default"],
|
|
"label": control["label"],
|
|
"tooltip": control["tooltip"],
|
|
}
|
|
if control["type"] == "STRING":
|
|
options.setdefault("placeholder", "torch.sqrt(latent_in['samples'].var())")
|
|
optional_inputs[control["name"]] = (control["type"], options)
|
|
return {
|
|
"required": {
|
|
"mode": (
|
|
["monitor", "passthrough"],
|
|
{"default": "passthrough"},
|
|
),
|
|
},
|
|
"optional": optional_inputs,
|
|
}
|
|
|
|
@classmethod
|
|
def _slot_display_lookup(cls) -> Dict[str, Tuple[str, str]]:
|
|
return {name: (display, slot_type) for name, display, slot_type in cls.SLOT_DEFINITIONS}
|
|
|
|
def _slot_branch_suffix(self, slot_name: str) -> Optional[str]:
|
|
for suffix in self.BRANCH_DISPLAY_NAMES:
|
|
if slot_name.endswith(suffix):
|
|
base = slot_name[: -len(suffix)]
|
|
candidate = f"{base}{suffix}"
|
|
if candidate == slot_name:
|
|
return suffix
|
|
return None
|
|
|
|
def _slot_base_name(self, slot_name: str) -> str:
|
|
suffix = self._slot_branch_suffix(slot_name)
|
|
if suffix:
|
|
return slot_name[: -len(suffix)]
|
|
return slot_name
|
|
|
|
def _branch_label(self, suffix: Optional[str]) -> Optional[str]:
|
|
if suffix is None:
|
|
return None
|
|
return self.BRANCH_DISPLAY_NAMES.get(suffix)
|
|
|
|
def _slot_variants(self, base_name: str) -> List[str]:
|
|
variants: List[str] = []
|
|
for slot_name, _display, _slot_type in self.SLOT_DEFINITIONS:
|
|
if slot_name == base_name:
|
|
variants.append(slot_name)
|
|
continue
|
|
suffix = self._slot_branch_suffix(slot_name)
|
|
if suffix and self._slot_base_name(slot_name) == base_name:
|
|
variants.append(slot_name)
|
|
return variants
|
|
|
|
def _matching_branch_slot(self, base_name: str, target_slot: str) -> Optional[str]:
|
|
variants = self._slot_variants(base_name)
|
|
if not variants:
|
|
return None
|
|
target_suffix = self._slot_branch_suffix(target_slot)
|
|
if target_suffix:
|
|
for variant in variants:
|
|
if self._slot_branch_suffix(variant) == target_suffix:
|
|
return variant
|
|
return variants[0]
|
|
|
|
RETURN_TYPES: Tuple[str, ...] = tuple(slot[2] for slot in SLOT_DEFINITIONS)
|
|
RETURN_NAMES: Tuple[str, ...] = tuple(slot[1] for slot in SLOT_DEFINITIONS)
|
|
FUNCTION = "route"
|
|
CATEGORY = "h4 Toolkit/Debug"
|
|
|
|
def __init__(self) -> None:
|
|
self.logger = ToolkitLogger("h4_DebugATron3000")
|
|
|
|
def _summarise_tensor(self, tensor: torch.Tensor) -> str:
|
|
if tensor is None:
|
|
return "<none>"
|
|
return f"shape={tuple(tensor.shape)} dtype={tensor.dtype} device={tensor.device}"
|
|
|
|
def _tensor_stats(self, tensor: torch.Tensor) -> List[str]:
|
|
stats: List[str] = []
|
|
if tensor is None:
|
|
return stats
|
|
try:
|
|
with torch.no_grad():
|
|
flat = tensor.float() if tensor.is_floating_point() else tensor
|
|
stats.append(
|
|
"min={:.4f} max={:.4f}".format(
|
|
float(flat.min().item()), float(flat.max().item())
|
|
)
|
|
)
|
|
if tensor.is_floating_point():
|
|
stats.append("mean={:.4f} std={:.4f}".format(
|
|
float(tensor.mean().item()), float(tensor.std(unbiased=False).item())
|
|
))
|
|
except Exception as exc: # pragma: no cover - diagnostics best effort
|
|
stats.append(f"stats unavailable ({exc})")
|
|
return stats
|
|
|
|
def _summarise_object(self, obj: Any, *, depth: int = 0) -> str:
|
|
if obj is None:
|
|
return "<none>"
|
|
if torch.is_tensor(obj):
|
|
return self._summarise_tensor(obj)
|
|
if isinstance(obj, (list, tuple)):
|
|
length = len(obj)
|
|
if length == 0:
|
|
return f"{type(obj).__name__}[0]"
|
|
if depth >= 1:
|
|
return f"{type(obj).__name__}[{length}]"
|
|
head = obj[0]
|
|
head_summary = self._summarise_object(head, depth=depth + 1)
|
|
return f"{type(obj).__name__}[{length}] first={head_summary}"
|
|
if isinstance(obj, dict):
|
|
keys = list(obj.keys())
|
|
preview = ", ".join(str(key) for key in keys[:4])
|
|
if len(keys) > 4:
|
|
preview += ", …"
|
|
return f"dict[{len(keys)}] keys={{{{ {preview} }}}}" if keys else "dict[0]"
|
|
label = None
|
|
for attr in ("name", "model_name", "filename"):
|
|
value = getattr(obj, attr, None)
|
|
if isinstance(value, str) and value:
|
|
label = value
|
|
break
|
|
if label:
|
|
return f"{type(obj).__name__}<{label}>"
|
|
return f"{type(obj).__name__}"
|
|
|
|
def _describe_payload(self, payload: Any, slot_type: str) -> List[str]:
|
|
details: List[str] = []
|
|
if payload is None:
|
|
return details
|
|
if torch.is_tensor(payload):
|
|
details.append(self._summarise_tensor(payload))
|
|
details.extend(self._tensor_stats(payload))
|
|
return details
|
|
if slot_type == "LATENT" and isinstance(payload, dict):
|
|
for key, value in payload.items():
|
|
summary = self._summarise_object(value)
|
|
line = f"{key}: {summary}"
|
|
details.append(line)
|
|
if torch.is_tensor(value):
|
|
for stat in self._tensor_stats(value):
|
|
details.append(f" {stat}")
|
|
return details
|
|
if slot_type == "CONDITIONING" and isinstance(payload, (list, tuple)):
|
|
details.append(f"entries={len(payload)}")
|
|
for idx, item in enumerate(payload[:3]):
|
|
if isinstance(item, (list, tuple)) and item:
|
|
head = item[0]
|
|
head_summary = self._summarise_object(head)
|
|
details.append(f"[{idx}] primary={head_summary}")
|
|
if torch.is_tensor(head):
|
|
for stat in self._tensor_stats(head):
|
|
details.append(f" {stat}")
|
|
else:
|
|
details.append(f"[{idx}] {self._summarise_object(item)}")
|
|
if len(payload) > 3:
|
|
details.append("… truncated …")
|
|
return details
|
|
if isinstance(payload, dict):
|
|
details.append(self._summarise_object(payload))
|
|
return details
|
|
if isinstance(payload, (list, tuple)):
|
|
details.append(self._summarise_object(payload))
|
|
return details
|
|
return details
|
|
|
|
def _ensure_ultra_directory(self) -> Optional[str]:
|
|
base_dir: Optional[str] = None
|
|
get_temp_dir = getattr(folder_paths, "get_temp_directory", None)
|
|
if callable(get_temp_dir):
|
|
try:
|
|
candidate = get_temp_dir()
|
|
if isinstance(candidate, str):
|
|
base_dir = candidate
|
|
except Exception: # pragma: no cover - environment specific
|
|
base_dir = None
|
|
if not base_dir:
|
|
get_output_dir = getattr(folder_paths, "get_output_directory", None)
|
|
if callable(get_output_dir):
|
|
try:
|
|
candidate = get_output_dir()
|
|
if isinstance(candidate, str):
|
|
base_dir = candidate
|
|
except Exception: # pragma: no cover
|
|
base_dir = None
|
|
if not base_dir:
|
|
return None
|
|
target = os.path.join(base_dir, "h4_debugatron_ultra")
|
|
try:
|
|
os.makedirs(target, exist_ok=True)
|
|
except Exception as exc: # pragma: no cover - filesystem issues
|
|
self.logger.warn(f"Unable to create Go_Plus_ULTRA artefact directory: {exc}")
|
|
return None
|
|
return target
|
|
|
|
def _tensor_to_image(self, tensor: torch.Tensor) -> Optional[Any]:
|
|
if Image is None or tensor is None:
|
|
return None
|
|
try:
|
|
sample = tensor.detach()
|
|
if sample.ndim == 4:
|
|
sample = sample[0]
|
|
if sample.ndim == 3 and sample.shape[0] in {1, 3, 4}:
|
|
sample = sample.permute(1, 2, 0)
|
|
sample = sample.to(device="cpu", dtype=torch.float32).clamp(0.0, 1.0)
|
|
data = sample.numpy()
|
|
if data.ndim == 2:
|
|
data = np.expand_dims(data, axis=-1)
|
|
if data.shape[-1] == 1:
|
|
data = np.repeat(data, 3, axis=-1)
|
|
elif data.shape[-1] == 2:
|
|
padding = np.zeros((*data.shape[:-1], 1), dtype=data.dtype)
|
|
data = np.concatenate([data, padding], axis=-1)
|
|
data_uint8 = np.clip(data * 255.0, 0, 255).astype(np.uint8)
|
|
return Image.fromarray(data_uint8)
|
|
except Exception as exc: # pragma: no cover - diagnostic best effort
|
|
self.logger.warn(f"Failed to convert tensor to image preview: {exc}")
|
|
return None
|
|
|
|
def _decode_latent_tensor(self, vae: Any, latent: Any) -> Optional[torch.Tensor]:
|
|
if latent is None or vae is None:
|
|
return None
|
|
tensor = None
|
|
if isinstance(latent, dict):
|
|
tensor = latent.get("samples")
|
|
elif torch.is_tensor(latent):
|
|
tensor = latent
|
|
if tensor is None:
|
|
return None
|
|
decode_candidates: List[Callable[..., Any]] = []
|
|
if latent_preview is not None:
|
|
for name in ("decode_latent_preview", "decode_latent", "decode"):
|
|
candidate = getattr(latent_preview, name, None)
|
|
if callable(candidate):
|
|
decode_candidates.append(candidate)
|
|
comfy_decode = getattr(comfy_utils, "decode_latent_preview", None)
|
|
if callable(comfy_decode):
|
|
decode_candidates.append(comfy_decode)
|
|
for candidate in decode_candidates:
|
|
try:
|
|
decoded = candidate(vae, latent if isinstance(latent, dict) else {"samples": tensor})
|
|
if isinstance(decoded, (tuple, list)):
|
|
decoded = decoded[0]
|
|
if isinstance(decoded, dict):
|
|
decoded = decoded.get("image") or decoded.get("samples")
|
|
if torch.is_tensor(decoded):
|
|
return decoded
|
|
except Exception:
|
|
continue
|
|
direct_decode = getattr(vae, "decode", None)
|
|
if callable(direct_decode):
|
|
try:
|
|
decoded = direct_decode(tensor)
|
|
if isinstance(decoded, (tuple, list)):
|
|
decoded = decoded[0]
|
|
if isinstance(decoded, dict):
|
|
decoded = decoded.get("image") or decoded.get("samples")
|
|
if torch.is_tensor(decoded):
|
|
return decoded
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
def _collect_latent_snapshots(
|
|
self,
|
|
latent: Any,
|
|
capture_first: bool,
|
|
capture_mid: bool,
|
|
capture_last: bool,
|
|
) -> List[Dict[str, Any]]:
|
|
snapshots: List[Dict[str, Any]] = []
|
|
if latent is None:
|
|
return snapshots
|
|
history: List[torch.Tensor] = []
|
|
if isinstance(latent, dict):
|
|
for key in ("samples_history", "history", "frames"):
|
|
candidate = latent.get(key)
|
|
if isinstance(candidate, (list, tuple)) and candidate:
|
|
history = [item for item in candidate if torch.is_tensor(item)]
|
|
if history:
|
|
break
|
|
samples = latent.get("samples")
|
|
if torch.is_tensor(samples):
|
|
history = history or [samples]
|
|
elif torch.is_tensor(latent):
|
|
history = [latent]
|
|
if not history:
|
|
return snapshots
|
|
picked_indices: List[Tuple[int, str]] = []
|
|
if capture_first and history:
|
|
picked_indices.append((0, "first"))
|
|
if capture_mid and len(history) > 2:
|
|
picked_indices.append((len(history) // 2, "mid"))
|
|
elif capture_mid and history:
|
|
picked_indices.append((0, "mid"))
|
|
if capture_last and history:
|
|
picked_indices.append((len(history) - 1, "last"))
|
|
seen = set()
|
|
for index, label in picked_indices:
|
|
index = max(0, min(index, len(history) - 1))
|
|
key = (index, label)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
tensor = history[index]
|
|
snapshots.append({"label": label, "tensor": tensor})
|
|
return snapshots
|
|
|
|
def _tensor_anomalies(self, tensor: torch.Tensor, label: str) -> List[str]:
|
|
anomalies: List[str] = []
|
|
if tensor is None:
|
|
return anomalies
|
|
try:
|
|
finite_mask = torch.isfinite(tensor) if tensor.is_floating_point() else None
|
|
if tensor.is_floating_point():
|
|
if torch.isnan(tensor).any():
|
|
anomalies.append(f"{label}: contains NaN values")
|
|
if torch.isinf(tensor).any():
|
|
anomalies.append(f"{label}: contains Inf values")
|
|
if finite_mask is not None and finite_mask.any():
|
|
std_value = float(tensor[finite_mask].float().std(unbiased=False).item())
|
|
if std_value > 50.0:
|
|
anomalies.append(f"{label}: unusually high std {std_value:.2f}")
|
|
if tensor.ndim >= 2:
|
|
max_abs = float(tensor.detach().abs().max().item())
|
|
if max_abs > 1000:
|
|
anomalies.append(f"{label}: extreme magnitude {max_abs:.2f}")
|
|
except Exception as exc: # pragma: no cover - defensive diagnostics
|
|
anomalies.append(f"{label}: anomaly check failed ({exc})")
|
|
return anomalies
|
|
|
|
def _evaluate_watch_expression(self, expression: str, context: Dict[str, Any]) -> Any:
|
|
safe_globals = {"torch": torch, "np": np}
|
|
try:
|
|
return eval(expression, {"__builtins__": {}}, {**safe_globals, **context})
|
|
except Exception as exc:
|
|
self.logger.warn(f"Watch expression '{expression}' failed: {exc}")
|
|
return None
|
|
|
|
def _preview_conditioning_tokens(self, conditioning: Any) -> List[str]:
|
|
previews: List[str] = []
|
|
if not isinstance(conditioning, (list, tuple)) or not conditioning:
|
|
return previews
|
|
for idx, entry in enumerate(conditioning[:3]):
|
|
fragments: List[str] = []
|
|
if isinstance(entry, (list, tuple)):
|
|
for component in entry:
|
|
if isinstance(component, dict):
|
|
for key in ("prompt", "text", "tokens"):
|
|
fragment = component.get(key)
|
|
if isinstance(fragment, str) and fragment.strip():
|
|
fragments.append(fragment.strip())
|
|
elif isinstance(component, str) and component.strip():
|
|
fragments.append(component.strip())
|
|
elif isinstance(entry, dict):
|
|
for key in ("prompt", "text", "tokens"):
|
|
fragment = entry.get(key)
|
|
if isinstance(fragment, str) and fragment.strip():
|
|
fragments.append(fragment.strip())
|
|
elif isinstance(entry, str) and entry.strip():
|
|
fragments.append(entry.strip())
|
|
if fragments:
|
|
merged = " | ".join(fragments)
|
|
previews.append(f"[{idx}] {merged[:200]}")
|
|
return previews
|
|
|
|
def _fingerprint_model(self, model: Any) -> Optional[str]:
|
|
if model is None or not hasattr(model, "state_dict"):
|
|
return None
|
|
try:
|
|
state = model.state_dict()
|
|
except Exception as exc:
|
|
self.logger.warn(f"Unable to access model state_dict for fingerprinting: {exc}")
|
|
return None
|
|
hasher = hashlib.sha1()
|
|
try:
|
|
for key in sorted(state.keys()):
|
|
value = state[key]
|
|
hasher.update(key.encode("utf-8"))
|
|
if isinstance(value, torch.Tensor):
|
|
hasher.update(str(tuple(value.shape)).encode("utf-8"))
|
|
hasher.update(str(value.dtype).encode("utf-8"))
|
|
flat = value.detach().view(-1)
|
|
sample = flat[: min(flat.numel(), 16)].to(device="cpu", dtype=torch.float32)
|
|
hasher.update(sample.numpy().tobytes())
|
|
else:
|
|
hasher.update(str(type(value)).encode("utf-8"))
|
|
except Exception as exc:
|
|
self.logger.warn(f"Model fingerprinting failed: {exc}")
|
|
return None
|
|
return hasher.hexdigest()
|
|
|
|
def _write_ultra_json_report(
|
|
self,
|
|
payload: Dict[str, Any],
|
|
cache_artifacts: bool,
|
|
) -> Optional[str]:
|
|
target_dir = self._ensure_ultra_directory()
|
|
if target_dir is None or not cache_artifacts:
|
|
return None
|
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f")
|
|
path = os.path.join(target_dir, f"debugatron_ultra_{timestamp}.json")
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
|
return path
|
|
except Exception as exc:
|
|
self.logger.warn(f"Failed to persist Go_Plus_ULTRA JSON log: {exc}")
|
|
return None
|
|
|
|
def _extract_vae_from_model(self, model: Any) -> Optional[Any]:
|
|
if model is None:
|
|
return None
|
|
candidate = self._find_attr_chain(model, self._VAE_ATTR_CANDIDATES)
|
|
if candidate is not None:
|
|
return candidate
|
|
for intermediary_name in self._MODEL_ATTR_CANDIDATES:
|
|
intermediary = getattr(model, intermediary_name, None)
|
|
if intermediary is None:
|
|
continue
|
|
candidate = self._find_attr_chain(intermediary, self._VAE_ATTR_CANDIDATES)
|
|
if candidate is not None:
|
|
return candidate
|
|
return None
|
|
|
|
def _find_attr_chain(self, obj: Any, names: Iterable[str]) -> Optional[Any]:
|
|
for name in names:
|
|
if hasattr(obj, name):
|
|
value = getattr(obj, name)
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
def _clone_payload_by_type(self, payload: Any, slot_type: str) -> Any:
|
|
if payload is None:
|
|
return None
|
|
if slot_type == "LATENT":
|
|
return clone_latent(payload)
|
|
if slot_type == "CONDITIONING":
|
|
return clone_conditioning_payload(payload)
|
|
if slot_type in {"IMAGE", "MASK"}:
|
|
return _clone_generic_payload(payload)
|
|
if slot_type in {"MODEL", "CLIP", "CLIP_VISION", "VAE"}:
|
|
return payload
|
|
return _clone_generic_payload(payload)
|
|
|
|
def _clone_payload_safely(self, payload: Any, slot_type: str) -> Any:
|
|
if payload is None:
|
|
return None
|
|
try:
|
|
return self._clone_payload_by_type(payload, slot_type)
|
|
except Exception as exc:
|
|
self.logger.warn(
|
|
f"Failed to clone {slot_type} payload for debug router; passing original. {exc}"
|
|
)
|
|
return payload
|
|
|
|
def route(
|
|
self,
|
|
mode: str,
|
|
go_ultra: bool = False,
|
|
ultra_capture_first_step: bool = ULTRA_CONTROL_DEFAULTS["ultra_capture_first_step"],
|
|
ultra_capture_mid_step: bool = ULTRA_CONTROL_DEFAULTS["ultra_capture_mid_step"],
|
|
ultra_capture_last_step: bool = ULTRA_CONTROL_DEFAULTS["ultra_capture_last_step"],
|
|
ultra_preview_images: bool = ULTRA_CONTROL_DEFAULTS["ultra_preview_images"],
|
|
ultra_json_log: bool = ULTRA_CONTROL_DEFAULTS["ultra_json_log"],
|
|
ultra_highlight_missing_conditioning: bool = ULTRA_CONTROL_DEFAULTS["ultra_highlight_missing_conditioning"],
|
|
ultra_token_preview: bool = ULTRA_CONTROL_DEFAULTS["ultra_token_preview"],
|
|
ultra_latent_anomaly_checks: bool = ULTRA_CONTROL_DEFAULTS["ultra_latent_anomaly_checks"],
|
|
ultra_model_diff_tracking: bool = ULTRA_CONTROL_DEFAULTS["ultra_model_diff_tracking"],
|
|
ultra_watch_expression: str = ULTRA_CONTROL_DEFAULTS["ultra_watch_expression"],
|
|
ultra_cache_artifacts: bool = ULTRA_CONTROL_DEFAULTS["ultra_cache_artifacts"],
|
|
**dynamic_slots: Any,
|
|
) -> Tuple[Any, ...]:
|
|
requested_mode = mode
|
|
mode_normalised = (mode or "passthrough").strip().lower()
|
|
if mode_normalised not in {"monitor", "passthrough"}:
|
|
self.logger.warn(
|
|
f"Debug router received unsupported mode '{requested_mode}'; falling back to passthrough"
|
|
)
|
|
mode_normalised = "passthrough"
|
|
self.logger.info(f"Debug router engaged :: mode={mode_normalised}")
|
|
prefix = "[h4_Debug-a-Tron-3000]"
|
|
outputs: List[Any] = []
|
|
connections = 0
|
|
summary_lines: List[str] = []
|
|
slot_reports: List[Dict[str, Any]] = []
|
|
anomaly_messages: List[str] = []
|
|
missing_conditioning: List[str] = []
|
|
token_previews: Dict[str, List[str]] = {}
|
|
preview_paths: List[str] = []
|
|
fallback_notes: List[str] = []
|
|
context_snapshot = dict(dynamic_slots)
|
|
for legacy_name, canonical_name in self.LEGACY_INPUT_ALIASES.items():
|
|
if canonical_name not in dynamic_slots and legacy_name in dynamic_slots:
|
|
dynamic_slots[canonical_name] = dynamic_slots[legacy_name]
|
|
context_snapshot[canonical_name] = dynamic_slots[legacy_name]
|
|
self.logger.warn(
|
|
f"Detected legacy input '{legacy_name}'. Please reconnect to '{canonical_name}' for future runs."
|
|
)
|
|
context_snapshot.update(
|
|
{
|
|
"go_ultra": go_ultra,
|
|
"ultra_capture_first_step": ultra_capture_first_step,
|
|
"ultra_capture_mid_step": ultra_capture_mid_step,
|
|
"ultra_capture_last_step": ultra_capture_last_step,
|
|
"ultra_preview_images": ultra_preview_images,
|
|
"ultra_json_log": ultra_json_log,
|
|
"ultra_highlight_missing_conditioning": ultra_highlight_missing_conditioning,
|
|
"ultra_token_preview": ultra_token_preview,
|
|
"ultra_latent_anomaly_checks": ultra_latent_anomaly_checks,
|
|
"ultra_model_diff_tracking": ultra_model_diff_tracking,
|
|
"ultra_watch_expression": ultra_watch_expression,
|
|
"ultra_cache_artifacts": ultra_cache_artifacts,
|
|
}
|
|
)
|
|
slot_lookup = self._slot_display_lookup()
|
|
branch_stats: Dict[str, Dict[str, Any]] = {
|
|
suffix: {"label": label, "total": 0, "connected": 0, "slots": []}
|
|
for suffix, label in self.BRANCH_DISPLAY_NAMES.items()
|
|
}
|
|
for slot_name, display_name, slot_type in self.SLOT_DEFINITIONS:
|
|
value = dynamic_slots.get(slot_name)
|
|
if value is None and slot_name in self.VAE_MODEL_FALLBACKS:
|
|
model_slot = self.VAE_MODEL_FALLBACKS[slot_name]
|
|
model_payload = dynamic_slots.get(model_slot)
|
|
fallback = self._extract_vae_from_model(model_payload)
|
|
if fallback is not None:
|
|
value = fallback
|
|
dynamic_slots[slot_name] = fallback
|
|
context_snapshot[slot_name] = fallback
|
|
source_display, _ = slot_lookup.get(model_slot, (model_slot, ""))
|
|
fallback_message = (
|
|
f"{display_name} missing; adopted VAE from {source_display}"
|
|
)
|
|
fallback_notes.append(fallback_message)
|
|
self.logger.info(f"{prefix} :: {fallback_message}")
|
|
descriptor = "<disconnected>"
|
|
details: List[str] = []
|
|
if value is not None:
|
|
connections += 1
|
|
descriptor = self._summarise_object(value)
|
|
message = f"{prefix} :: {display_name} ({slot_type}) -> {descriptor}"
|
|
details = self._describe_payload(value, slot_type)
|
|
summary_lines.append(f"{display_name} ({slot_type}) -> {descriptor}")
|
|
summary_lines.extend(f" {line}" for line in details)
|
|
self.logger.info(message)
|
|
for detail in details:
|
|
self.logger.info(f" {detail}")
|
|
else:
|
|
self.logger.trace(f"{prefix} :: {display_name} ({slot_type}) -> <disconnected>")
|
|
cloned_value = (
|
|
self._clone_payload_safely(value, slot_type)
|
|
if mode_normalised in {"passthrough", "monitor"}
|
|
else None
|
|
)
|
|
outputs.append(cloned_value)
|
|
slot_reports.append(
|
|
{
|
|
"slot_name": slot_name,
|
|
"display_name": display_name,
|
|
"slot_type": slot_type,
|
|
"connected": value is not None,
|
|
"descriptor": descriptor,
|
|
"details": details,
|
|
}
|
|
)
|
|
branch_suffix = self._slot_branch_suffix(slot_name)
|
|
if branch_suffix and branch_suffix in branch_stats:
|
|
info = branch_stats[branch_suffix]
|
|
info["total"] += 1
|
|
if value is not None:
|
|
info["connected"] += 1
|
|
info["slots"].append(
|
|
{
|
|
"display_name": display_name,
|
|
"connected": value is not None,
|
|
"descriptor": descriptor if value is not None else None,
|
|
}
|
|
)
|
|
if go_ultra and ultra_latent_anomaly_checks and value is not None:
|
|
if torch.is_tensor(value):
|
|
anomaly_messages.extend(self._tensor_anomalies(value, display_name))
|
|
elif isinstance(value, dict):
|
|
for key, tensor in value.items():
|
|
if torch.is_tensor(tensor):
|
|
anomaly_messages.extend(
|
|
self._tensor_anomalies(tensor, f"{display_name}.{key}")
|
|
)
|
|
if connections == 0:
|
|
self.logger.info("Debug router found no connected inputs")
|
|
else:
|
|
self.logger.info(f"Debug router captured {connections} input(s)")
|
|
|
|
latent_slots = self._slot_variants("latent_in")
|
|
vae_slots = self._slot_variants("vae_in")
|
|
latent_payloads: Dict[str, Any] = {name: dynamic_slots.get(name) for name in latent_slots}
|
|
vae_payloads: Dict[str, Any] = {name: dynamic_slots.get(name) for name in vae_slots}
|
|
|
|
if go_ultra and ultra_highlight_missing_conditioning:
|
|
for base_name, label in CONDITIONING_SLOT_LABELS.items():
|
|
for slot_name in self._slot_variants(base_name):
|
|
candidate = dynamic_slots.get(slot_name)
|
|
display_label = label
|
|
branch_label = self._branch_label(self._slot_branch_suffix(slot_name))
|
|
if branch_label:
|
|
display_label = f"{label} ({branch_label})"
|
|
if candidate is None or (isinstance(candidate, (list, tuple)) and len(candidate) == 0):
|
|
warning = f"Go_Plus_ULTRA :: {display_label} input appears to be missing"
|
|
missing_conditioning.append(warning)
|
|
self.logger.warn(warning)
|
|
|
|
if go_ultra and ultra_token_preview:
|
|
for base_name, label in (
|
|
("conditioning_in", "Conditioning"),
|
|
("conditioning_positive_in", "Positive"),
|
|
("conditioning_negative_in", "Negative"),
|
|
):
|
|
for slot_name in self._slot_variants(base_name):
|
|
candidate = dynamic_slots.get(slot_name)
|
|
branch_label = self._branch_label(self._slot_branch_suffix(slot_name))
|
|
display_label = label if branch_label is None else f"{label} ({branch_label})"
|
|
snippets = self._preview_conditioning_tokens(candidate)
|
|
if snippets:
|
|
token_previews[display_label] = snippets
|
|
self.logger.info(f"Go_Plus_ULTRA :: {display_label} token preview:")
|
|
for snippet in snippets:
|
|
self.logger.info(f" {snippet}")
|
|
|
|
latent_snapshots_map: Dict[str, List[Dict[str, Any]]] = {}
|
|
if go_ultra:
|
|
for slot_name, payload in latent_payloads.items():
|
|
if payload is None:
|
|
continue
|
|
snapshots = self._collect_latent_snapshots(
|
|
payload,
|
|
ultra_capture_first_step,
|
|
ultra_capture_mid_step,
|
|
ultra_capture_last_step,
|
|
)
|
|
if snapshots:
|
|
latent_snapshots_map[slot_name] = snapshots
|
|
for slot_name, snapshots in latent_snapshots_map.items():
|
|
slot_display, _slot_type = slot_lookup.get(slot_name, (slot_name, "LATENT"))
|
|
branch_suffix = self._slot_branch_suffix(slot_name)
|
|
branch_label = self._branch_label(branch_suffix)
|
|
vae_slot = self._matching_branch_slot("vae_in", slot_name)
|
|
vae_payload = vae_payloads.get(vae_slot) if vae_slot else None
|
|
for snapshot in snapshots:
|
|
tensor = snapshot.get("tensor")
|
|
if tensor is None:
|
|
continue
|
|
label = snapshot.get("label", "snapshot")
|
|
summary_label = f"{slot_display} [{label}]"
|
|
summary = self._summarise_tensor(tensor)
|
|
summary_lines.append(f"Snapshot[{summary_label}] -> {summary}")
|
|
self.logger.info(f"Go_Plus_ULTRA :: Snapshot[{summary_label}] -> {summary}")
|
|
if go_ultra and ultra_latent_anomaly_checks:
|
|
label_descriptor = summary_label if branch_label is None else f"{branch_label} {label}"
|
|
anomaly_messages.extend(self._tensor_anomalies(tensor, f"Latent snapshot {label_descriptor}"))
|
|
if go_ultra and ultra_preview_images and vae_payload is not None:
|
|
decoded = self._decode_latent_tensor(vae_payload, {"samples": tensor})
|
|
image_tensor = decoded if torch.is_tensor(decoded) else None
|
|
image = self._tensor_to_image(image_tensor) if image_tensor is not None else None
|
|
if image is not None:
|
|
if ultra_cache_artifacts:
|
|
directory = self._ensure_ultra_directory()
|
|
if directory is not None:
|
|
filename = (
|
|
f"ultra_preview_{slot_name}_{label}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S_%f')}.png"
|
|
)
|
|
path = os.path.join(directory, filename)
|
|
try:
|
|
image.save(path)
|
|
preview_paths.append(path)
|
|
summary_lines.append(f"Preview[{summary_label}] saved -> {path}")
|
|
self.logger.info(
|
|
f"Go_Plus_ULTRA :: Saved preview image ({summary_label}) -> {path}"
|
|
)
|
|
except Exception as exc:
|
|
self.logger.warn(
|
|
f"Go_Plus_ULTRA :: Failed to write preview image ({summary_label}): {exc}"
|
|
)
|
|
else:
|
|
summary_lines.append(
|
|
f"Preview[{summary_label}] generated (caching disabled)"
|
|
)
|
|
self.logger.info(
|
|
f"Go_Plus_ULTRA :: Preview image ({summary_label}) generated (caching disabled)"
|
|
)
|
|
|
|
if branch_stats:
|
|
branch_summary_lines: List[str] = []
|
|
for suffix, info in branch_stats.items():
|
|
if info["total"] == 0:
|
|
continue
|
|
line = f"{info['label']}: {info['connected']}/{info['total']} connected"
|
|
branch_summary_lines.append(line)
|
|
for slot_info in info["slots"]:
|
|
if not slot_info["connected"]:
|
|
branch_summary_lines.append(f" missing -> {slot_info['display_name']}")
|
|
if branch_summary_lines:
|
|
summary_lines.append("Branch summaries:")
|
|
summary_lines.extend(branch_summary_lines)
|
|
|
|
fingerprint_records: Dict[str, Optional[str]] = {}
|
|
if go_ultra and ultra_model_diff_tracking:
|
|
for slot_name, display_name in (
|
|
("model_in", "Model"),
|
|
("vae_in", "VAE"),
|
|
("clip_in", "CLIP"),
|
|
):
|
|
payload = dynamic_slots.get(slot_name)
|
|
fingerprint = self._fingerprint_model(payload)
|
|
if fingerprint:
|
|
fingerprint_records[display_name] = fingerprint
|
|
summary_lines.append(f"Fingerprint[{display_name}] -> {fingerprint}")
|
|
self.logger.info(f"Go_Plus_ULTRA :: {display_name} fingerprint {fingerprint}")
|
|
|
|
watch_value: Any = None
|
|
if go_ultra and ultra_watch_expression and ultra_watch_expression.strip():
|
|
watch_value = self._evaluate_watch_expression(ultra_watch_expression, context_snapshot)
|
|
if watch_value is not None:
|
|
summary_lines.append(f"Watch[{ultra_watch_expression}] -> {watch_value}")
|
|
self.logger.info(f"Go_Plus_ULTRA :: Watch[{ultra_watch_expression}] -> {watch_value}")
|
|
|
|
if go_ultra and ultra_json_log:
|
|
json_payload = {
|
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
"mode": mode_normalised,
|
|
"connections": connections,
|
|
"settings": {
|
|
"capture_first": ultra_capture_first_step,
|
|
"capture_mid": ultra_capture_mid_step,
|
|
"capture_last": ultra_capture_last_step,
|
|
"preview_images": ultra_preview_images,
|
|
"json_log": ultra_json_log,
|
|
"highlight_missing_conditioning": ultra_highlight_missing_conditioning,
|
|
"token_preview": ultra_token_preview,
|
|
"latent_anomaly_checks": ultra_latent_anomaly_checks,
|
|
"model_diff_tracking": ultra_model_diff_tracking,
|
|
"watch_expression": ultra_watch_expression,
|
|
"cache_artifacts": ultra_cache_artifacts,
|
|
},
|
|
"slots": slot_reports,
|
|
"anomalies": anomaly_messages,
|
|
"missing_conditioning": missing_conditioning,
|
|
"token_previews": token_previews,
|
|
"preview_paths": preview_paths,
|
|
"fingerprints": fingerprint_records,
|
|
"watch_result": str(watch_value) if watch_value is not None else None,
|
|
}
|
|
json_path = self._write_ultra_json_report(json_payload, ultra_cache_artifacts)
|
|
if json_path:
|
|
summary_lines.append(f"JSON log saved -> {json_path}")
|
|
self.logger.info(f"Go_Plus_ULTRA :: JSON log saved -> {json_path}")
|
|
elif ultra_cache_artifacts:
|
|
self.logger.warn("Go_Plus_ULTRA :: Requested JSON persistence failed")
|
|
else:
|
|
summary_lines.append("JSON log generated (not persisted)")
|
|
self.logger.info("Go_Plus_ULTRA :: JSON log generated (caching disabled)")
|
|
|
|
if go_ultra and ultra_latent_anomaly_checks:
|
|
for anomaly in anomaly_messages:
|
|
self.logger.warn(f"Go_Plus_ULTRA :: {anomaly}")
|
|
summary_lines.extend(f"Anomaly: {msg}" for msg in anomaly_messages)
|
|
|
|
if missing_conditioning:
|
|
summary_lines.extend(f"WARNING: {msg}" for msg in missing_conditioning)
|
|
|
|
if fallback_notes:
|
|
summary_lines.extend(f"NOTE: {note}" for note in fallback_notes)
|
|
|
|
status_lines = [
|
|
f"Mode: {mode_normalised}",
|
|
f"Connections: {connections}",
|
|
f"Go_Plus_ULTRA: {'ON' if go_ultra else 'OFF'}",
|
|
]
|
|
if token_previews:
|
|
summary_lines.append("Token previews:")
|
|
for label, snippets in token_previews.items():
|
|
for snippet in snippets:
|
|
summary_lines.append(f" {label}: {snippet}")
|
|
if summary_lines:
|
|
status_lines.append("")
|
|
status_lines.extend(summary_lines)
|
|
else:
|
|
status_lines.append("")
|
|
status_lines.append("No inputs connected.")
|
|
|
|
status_text = "\n".join(status_lines)
|
|
self.logger.info(status_text)
|
|
self.logger.info("Debug routing complete")
|
|
return tuple(outputs)
|
|
|
|
|
|
class h4_DebugATronRouter(h4_DebugATron3000):
|
|
"""Secondary router variant that always forwards signals downstream."""
|
|
|
|
SLOT_DEFINITIONS = ROUTER_SLOT_DEFINITIONS
|
|
SLOT_TOOLTIPS = ROUTER_SLOT_TOOLTIPS
|
|
VAE_MODEL_FALLBACKS = ROUTER_VAE_MODEL_FALLBACKS
|
|
BRANCH_DISPLAY_NAMES = ROUTER_BRANCH_DISPLAY_NAMES
|
|
RETURN_TYPES = ROUTER_SLOT_RETURN_TYPES
|
|
RETURN_NAMES = ROUTER_SLOT_RETURN_NAMES
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Any]: # noqa: N802
|
|
config = super().INPUT_TYPES()
|
|
config["required"]["mode"] = (["monitor", "passthrough"], {"default": "passthrough"})
|
|
return config
|
|
|
|
|
|
class h4_ExecutionLogger(h4_DebugATron3000):
|
|
"""Console mirror node that summarises connected payloads into a log string."""
|
|
|
|
SLOT_DEFINITIONS = DEBUG_SLOT_DEFINITIONS
|
|
SLOT_TOOLTIPS = DEBUG_SLOT_TOOLTIPS
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("log_summary",)
|
|
FUNCTION = "log"
|
|
CATEGORY = "h4 Toolkit/Debug"
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Any]: # noqa: N802
|
|
optional_inputs: Dict[str, Tuple[str, Dict[str, Any]]] = {}
|
|
for slot_name, display_name, slot_type in cls.SLOT_DEFINITIONS:
|
|
tooltip = cls.SLOT_TOOLTIPS.get(slot_name)
|
|
options: Dict[str, Any] = {"default": None, "label": display_name}
|
|
if tooltip:
|
|
options["tooltip"] = tooltip
|
|
optional_inputs[slot_name] = (slot_type, options)
|
|
optional_inputs.update(
|
|
{
|
|
"show_types": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": True,
|
|
"label": "Show payload types",
|
|
"tooltip": EXECUTION_LOGGER_WIDGET_TOOLTIPS.get("show_types"),
|
|
},
|
|
),
|
|
"show_shapes": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": True,
|
|
"label": "Show tensor shapes",
|
|
"tooltip": EXECUTION_LOGGER_WIDGET_TOOLTIPS.get("show_shapes"),
|
|
},
|
|
),
|
|
}
|
|
)
|
|
return {
|
|
"required": {
|
|
"log_level": (
|
|
["INFO", "DEBUG", "WARNING"],
|
|
{
|
|
"default": "INFO",
|
|
"label": "Log level",
|
|
"tooltip": EXECUTION_LOGGER_WIDGET_TOOLTIPS.get("log_level"),
|
|
},
|
|
),
|
|
"prefix": (
|
|
"STRING",
|
|
{
|
|
"default": "[EXEC]",
|
|
"tooltip": EXECUTION_LOGGER_WIDGET_TOOLTIPS.get("prefix"),
|
|
},
|
|
),
|
|
},
|
|
"optional": optional_inputs,
|
|
}
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.logger = ToolkitLogger("h4_ExecutionLogger")
|
|
|
|
def log(
|
|
self,
|
|
log_level: str,
|
|
prefix: str,
|
|
show_types: bool = True,
|
|
show_shapes: bool = True,
|
|
**dynamic_slots: Any,
|
|
) -> Tuple[str]:
|
|
level = (log_level or "INFO").strip().upper()
|
|
log_methods = {
|
|
"DEBUG": self.logger.debug,
|
|
"INFO": self.logger.info,
|
|
"WARNING": self.logger.warn,
|
|
}
|
|
emit = log_methods.get(level, self.logger.info)
|
|
summary_lines: List[str] = []
|
|
for slot_name, display_name, slot_type in self.SLOT_DEFINITIONS:
|
|
payload = dynamic_slots.get(slot_name)
|
|
if payload is None:
|
|
continue
|
|
descriptor = self._summarise_object(payload)
|
|
line = f"{prefix} {display_name}: {descriptor}".strip()
|
|
emit(line)
|
|
summary_lines.append(f"{display_name}: {descriptor}")
|
|
if show_types:
|
|
type_name = type(payload).__name__
|
|
type_line = f" type={type_name}"
|
|
emit(f"{prefix} {type_line}".strip())
|
|
summary_lines.append(type_line)
|
|
if show_shapes:
|
|
for detail in self._describe_payload(payload, slot_type):
|
|
detail_line = f" {detail}"
|
|
emit(f"{prefix} {detail}".strip())
|
|
summary_lines.append(detail_line)
|
|
if not summary_lines:
|
|
summary_lines.append(f"{prefix} No inputs connected.")
|
|
emit(summary_lines[0])
|
|
summary_text = "\n".join(summary_lines)
|
|
return (summary_text,)
|
|
|
|
|
|
class h4_DebugATron3000Console(h4_DebugATron3000):
|
|
"""Output-node variant that emits an HTML dossier alongside routed payloads."""
|
|
|
|
SLOT_DEFINITIONS = DEBUG_SLOT_DEFINITIONS
|
|
SLOT_TOOLTIPS = DEBUG_SLOT_TOOLTIPS
|
|
RETURN_TYPES = ("STRING",) + DEBUG_SLOT_RETURN_TYPES
|
|
RETURN_NAMES = ("html_log",) + DEBUG_SLOT_RETURN_NAMES
|
|
FUNCTION = "render"
|
|
CATEGORY = "h4 Toolkit/Debug"
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Any]: # noqa: N802
|
|
config = super().INPUT_TYPES()
|
|
optional = config.setdefault("optional", {})
|
|
optional.update(
|
|
{
|
|
"display_mode": (
|
|
["console_only", "html_only", "both"],
|
|
{
|
|
"default": "both",
|
|
"label": "Display mode",
|
|
"tooltip": "Choose whether to emit console logs, HTML, or both.",
|
|
},
|
|
),
|
|
"color_scheme": (
|
|
list(CONSOLE_COLOR_SCHEMES.keys()),
|
|
{
|
|
"default": "cyberpunk",
|
|
"label": "Colour scheme",
|
|
"tooltip": "Palette applied to the rendered HTML dossier.",
|
|
},
|
|
),
|
|
"verbosity": (
|
|
list(CONSOLE_VERBOSITY_LEVELS.keys()),
|
|
{
|
|
"default": "normal",
|
|
"label": "Verbosity",
|
|
"tooltip": "Controls how many diagnostic lines are included per slot.",
|
|
},
|
|
),
|
|
"show_timestamps": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": True,
|
|
"label": "Show timestamps",
|
|
"tooltip": "When enabled, each slot section includes the current time stamp.",
|
|
},
|
|
),
|
|
}
|
|
)
|
|
return config
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.logger = ToolkitLogger("h4_DebugATronConsole")
|
|
|
|
def render(
|
|
self,
|
|
mode: str,
|
|
display_mode: str = "both",
|
|
color_scheme: str = "cyberpunk",
|
|
verbosity: str = "normal",
|
|
show_timestamps: bool = True,
|
|
go_ultra: bool = False,
|
|
ultra_capture_first_step: bool = ULTRA_CONTROL_DEFAULTS["ultra_capture_first_step"],
|
|
ultra_capture_mid_step: bool = ULTRA_CONTROL_DEFAULTS["ultra_capture_mid_step"],
|
|
ultra_capture_last_step: bool = ULTRA_CONTROL_DEFAULTS["ultra_capture_last_step"],
|
|
ultra_preview_images: bool = ULTRA_CONTROL_DEFAULTS["ultra_preview_images"],
|
|
ultra_json_log: bool = ULTRA_CONTROL_DEFAULTS["ultra_json_log"],
|
|
ultra_highlight_missing_conditioning: bool = ULTRA_CONTROL_DEFAULTS["ultra_highlight_missing_conditioning"],
|
|
ultra_token_preview: bool = ULTRA_CONTROL_DEFAULTS["ultra_token_preview"],
|
|
ultra_latent_anomaly_checks: bool = ULTRA_CONTROL_DEFAULTS["ultra_latent_anomaly_checks"],
|
|
ultra_model_diff_tracking: bool = ULTRA_CONTROL_DEFAULTS["ultra_model_diff_tracking"],
|
|
ultra_watch_expression: str = ULTRA_CONTROL_DEFAULTS["ultra_watch_expression"],
|
|
ultra_cache_artifacts: bool = ULTRA_CONTROL_DEFAULTS["ultra_cache_artifacts"],
|
|
**dynamic_slots: Any,
|
|
) -> Tuple[str, ...]:
|
|
routed_outputs = super().route(
|
|
mode,
|
|
go_ultra=go_ultra,
|
|
ultra_capture_first_step=ultra_capture_first_step,
|
|
ultra_capture_mid_step=ultra_capture_mid_step,
|
|
ultra_capture_last_step=ultra_capture_last_step,
|
|
ultra_preview_images=ultra_preview_images,
|
|
ultra_json_log=ultra_json_log,
|
|
ultra_highlight_missing_conditioning=ultra_highlight_missing_conditioning,
|
|
ultra_token_preview=ultra_token_preview,
|
|
ultra_latent_anomaly_checks=ultra_latent_anomaly_checks,
|
|
ultra_model_diff_tracking=ultra_model_diff_tracking,
|
|
ultra_watch_expression=ultra_watch_expression,
|
|
ultra_cache_artifacts=ultra_cache_artifacts,
|
|
**dynamic_slots,
|
|
)
|
|
|
|
include_html = (display_mode or "both").lower() in {"html_only", "both"}
|
|
verbosity_limit = CONSOLE_VERBOSITY_LEVELS.get(verbosity, 2)
|
|
palette = CONSOLE_COLOR_SCHEMES.get(color_scheme, CONSOLE_COLOR_SCHEMES["cyberpunk"])
|
|
|
|
sections: List[str] = []
|
|
timestamp = datetime.utcnow().strftime("%H:%M:%S") if show_timestamps else ""
|
|
for slot_name, display_name, slot_type in self.SLOT_DEFINITIONS:
|
|
payload = dynamic_slots.get(slot_name)
|
|
connected = payload is not None
|
|
descriptor = self._summarise_object(payload) if connected else "<disconnected>"
|
|
details = self._describe_payload(payload, slot_type) if connected else []
|
|
if verbosity_limit >= 0:
|
|
details = details[:verbosity_limit]
|
|
escaped_title = html.escape(display_name)
|
|
escaped_descriptor = html.escape(descriptor)
|
|
detail_items = "".join(
|
|
f"<li>{html.escape(detail)}</li>" for detail in details
|
|
)
|
|
timestamp_html = f"<span class=\"timestamp\">{html.escape(timestamp)}</span>" if timestamp else ""
|
|
section = (
|
|
f"<section class=\"slot\">"
|
|
f"<header><span class=\"title\">{escaped_title}</span>{timestamp_html}</header>"
|
|
f"<div class=\"descriptor\">{escaped_descriptor}</div>"
|
|
)
|
|
if detail_items:
|
|
section += f"<ul class=\"details\">{detail_items}</ul>"
|
|
section += "</section>"
|
|
sections.append(section)
|
|
|
|
html_report = ""
|
|
if include_html:
|
|
css = textwrap.dedent(
|
|
f"""
|
|
<style>
|
|
.h4-console {{
|
|
background: {palette['background']};
|
|
color: {palette['text']};
|
|
font-family: 'Segoe UI', 'Roboto', sans-serif;
|
|
padding: 16px;
|
|
}}
|
|
.h4-console .slot {{
|
|
background: {palette['panel']};
|
|
border-left: 4px solid {palette['accent']};
|
|
margin-bottom: 12px;
|
|
padding: 12px 16px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
|
}}
|
|
.h4-console .slot header {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-weight: 600;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 6px;
|
|
color: {palette['accent']};
|
|
}}
|
|
.h4-console .slot .descriptor {{
|
|
font-size: 0.95rem;
|
|
margin-bottom: 4px;
|
|
}}
|
|
.h4-console .slot .details {{
|
|
list-style: disc;
|
|
margin: 0 0 0 20px;
|
|
color: {palette['muted']};
|
|
}}
|
|
.h4-console .timestamp {{
|
|
font-size: 0.8rem;
|
|
color: {palette['muted']};
|
|
}}
|
|
</style>
|
|
"""
|
|
).strip()
|
|
body = "".join(sections)
|
|
html_report = f"{css}<div class=\"h4-console\">{body}</div>"
|
|
|
|
return (html_report,) + routed_outputs
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"h4AxisDriver": h4_AxisDriver,
|
|
"h4PlotXY": h4_PlotXY,
|
|
"h4Varianator": h4_Varianator,
|
|
"h4DebugATron3000": h4_DebugATron3000,
|
|
"h4DebugATronRouter": h4_DebugATronRouter,
|
|
"h4DebugATron3000Console": h4_DebugATron3000Console,
|
|
"h4ExecutionLogger": h4_ExecutionLogger,
|
|
"h4SeedBroadcaster": h4_SeedBroadcaster,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"h4AxisDriver": f"h4 : Axis Driver (v{AXIS_DRIVER_VERSION})",
|
|
"h4PlotXY": f"h4 : The Engine (Simple Sampler+Plot) v{PLOT_NODE_VERSION}",
|
|
"h4Varianator": f"h4 : The Varianator (v{VARIANATOR_VERSION})",
|
|
"h4DebugATron3000": f"h4 : Debug-a-Tron-3000 (v{DEBUG_NODE_VERSION})",
|
|
"h4DebugATronRouter": f"h4 : Debug-a-Tron-3000 Router (v{DEBUG_NODE_VERSION})",
|
|
"h4DebugATron3000Console": "h4 : Debug-a-Tron-3000 CONSOLE (v3.0.0) - Now in Technicolor™",
|
|
"h4ExecutionLogger": "h4 : Execution Logger (v1.0.0) - Console Mirror",
|
|
"h4SeedBroadcaster": "h4 : Seed Broadcaster (v1.1.0) - Seed Generator",
|
|
}
|
|
|
|
NODE_TOOLTIP_MAPPINGS = {
|
|
"h4AxisDriver": AXIS_DRIVER_WIDGET_TOOLTIPS,
|
|
"h4PlotXY": PLOT_WIDGET_TOOLTIPS,
|
|
"h4Varianator": VARIANATOR_WIDGET_TOOLTIPS,
|
|
"h4DebugATron3000": DEBUG_WIDGET_TOOLTIPS,
|
|
"h4DebugATronRouter": DEBUG_WIDGET_TOOLTIPS,
|
|
"h4DebugATron3000Console": DEBUG_WIDGET_TOOLTIPS,
|
|
"h4ExecutionLogger": EXECUTION_LOGGER_WIDGET_TOOLTIPS,
|
|
"h4SeedBroadcaster": SEED_BROADCASTER_WIDGET_TOOLTIPS,
|
|
}
|
|
|
|
__all__ = [
|
|
"h4_AxisDriver",
|
|
"h4_PlotXY",
|
|
"h4_Varianator",
|
|
"h4_DebugATron3000",
|
|
"h4_DebugATronRouter",
|
|
"h4_DebugATron3000Console",
|
|
"h4_ExecutionLogger",
|
|
"h4_SeedBroadcaster",
|
|
"NODE_CLASS_MAPPINGS",
|
|
"NODE_DISPLAY_NAME_MAPPINGS",
|
|
"NODE_TOOLTIP_MAPPINGS",
|
|
]
|