Files
xiaolibai-sys-ComfyUI-Berni…/utils/types.py
T
Wen Li 91ce7daa7b Initial commit: ComfyUI-BerniniR Wrapper v1.0.0
Seven-mode guidance (CFG/APG/RAAG/S2/Z2/STG_A/STG_R), context-window sampling, segment prompt-travel, lazy streaming load, layered VRAM management.
2026-07-15 00:54:57 +08:00

337 lines
10 KiB
Python

"""
Typed data-transfer payloads for Bernini-R nodes.
This module centralises the structured data that flows between custom nodes.
ComfyUI native containers (CONDITIONING, LATENT, CLIP, VAE, IMAGE) keep their
external shape so other ComfyUI nodes can still consume them; only the custom
``BERNINI_*`` sockets are represented as typed dataclasses.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Iterator
import torch
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class ContextSchedule(str, Enum):
"""Context-window scheduling strategies."""
UNIFORM_STANDARD = "uniform_standard"
UNIFORM_LOOPED = "uniform_looped"
STATIC_STANDARD = "static_standard"
class FuseMethod(str, Enum):
"""Window-blending functions."""
LINEAR = "linear"
PYRAMID = "pyramid"
class GuidanceMode(str, Enum):
"""Guidance strategies supported by BerniniModelWrapper."""
CFG = "CFG"
APG = "APG"
RAAG = "RAAG"
S2 = "S2"
Z2 = "Z2"
STG_A = "STG_A"
STG_R = "STG_R"
@property
def is_stg(self) -> bool:
return self.value.startswith("STG")
@property
def stg_variant(self) -> str:
"""Return 'A' or 'R' for STG modes, empty string otherwise."""
return self.value[4:] if self.is_stg else ""
class MaskMode(str, Enum):
"""Differential-diffusion mask behaviours."""
ANNEAL = "anneal"
FREEZE = "freeze"
# ---------------------------------------------------------------------------
# Custom socket payloads
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class BerniniContext:
"""Temporal context-window configuration consumed by the sampler.
Pixel-frame values are converted to latent-frame values once at construction
time so the sampler no longer repeats the ``// 4`` arithmetic on every run.
"""
schedule: ContextSchedule
context_frames: int
context_stride: int
context_overlap: int
freenoise: bool
fuse_method: FuseMethod
rope_ntk_scale: float
latent_frames: int = field(init=False)
latent_overlap: int = field(init=False)
latent_stride: int = field(init=False)
def __post_init__(self):
# Wan VAE uses a 4x temporal downscale. ``(F - 1) // 4 + 1`` matches
# ComfyUI's latent-frame calculation for the last partial group.
object.__setattr__(
self, "latent_frames",
max(1, (self.context_frames - 1) // 4 + 1)
)
object.__setattr__(
self, "latent_overlap",
max(0, self.context_overlap // 4)
)
# Stride is provided in log2 levels by the UI; keep at least 1.
object.__setattr__(
self, "latent_stride",
max(1, self.context_stride // 4)
)
@dataclass(frozen=True)
class BerniniBlockSwap:
"""Block-swap / VRAM-offloading configuration."""
block_to_swap: int
prefetch: bool = True
prefetch_count: int = 1
pin_memory: bool = False
@dataclass(frozen=True)
class BerniniTeaCache:
"""TeaCache block-caching configuration."""
max_skip_blocks: int
start_block: int
rel_l1_thresh: float
warmup_steps: int
cooldown_steps: int
@dataclass(frozen=True)
class BerniniAttention:
"""Attention-backend selection returned by BerniniR_AttentionConfig."""
backend: str
force_backend: bool
available: tuple[str, ...]
best: str
@dataclass(frozen=True)
class BerniniGuidance:
"""Per-step guidance scale schedule."""
values: list[float]
curve: str
steps: int
start: float
end: float
def __post_init__(self):
# Defensive: ensure the list length matches the declared step count.
if len(self.values) != self.steps:
object.__setattr__(
self, "values",
[self.start] * self.steps if self.steps > 0 else []
)
@dataclass(frozen=True)
class BerniniGuidanceConfig:
"""Guidance strategy selection + per-mode hyper-parameters.
Replaces the 8 individual guidance widgets on the sampler node with
a single ``BERNINI_GUIDANCE_CONFIG`` socket, following the same pattern
as ``BERNINI_CTX`` / ``BERNINI_BLOCKSWAP`` / ``BERNINI_TEACACHE``.
"""
mode: GuidanceMode = GuidanceMode.CFG
# APG
apg_eta: float = 0.15
apg_rescale: bool = True
apg_momentum: float = 0.0
# RAAG
raag_alpha: float = 1.0
# S²
s2_omega: float = 1.0
# STG
stg_scale: float = 1.0
stg_block_idx: str = "10,20,27"
# Z²
z2_collapse: float = 0.3
# ---------------------------------------------------------------------------
# Segment schedule (hard-cut denoising)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SegmentSpec:
"""A single segment in a hard-cut prompt schedule.
Each segment has a single prompt and covers a contiguous frame range.
No interpolation — the prompt is constant across the segment.
"""
start_frame: int # 1-based pixel frame, inclusive
end_frame: int # 1-based pixel frame, inclusive
prompt: str # single prompt (no "/" syntax)
@dataclass
class EncodedSegment:
"""A segment with its CLIP-encoded prompt embedding.
Passed from ``BerniniR_SegmentSchedule`` to the sampler via the
``segment_specs`` key in the positive conditioning extra dict.
``start_latent`` / ``end_latent`` are the pre-computed latent-space frame
bounds (``end_latent`` exclusive) so the sampler's context-window framework
can build prompt-travel windows without repeating the pixel→latent math.
"""
start_frame: int
end_frame: int
embed: torch.Tensor
pooled: dict
start_latent: int = 0
end_latent: int = 0
@dataclass
class SegmentWindow:
"""A latent-space window paired with its prompt embedding.
Consumed by the context-window wrapper (``_build_context_window_wrapper``)
to run temporal prompt-travel: each window denoises with its own text
embedding, and adjacent windows crossfade in their overlap region. All Wan
text embeds share shape ``(1, 512, 4096)``, so swapping the embedding per
window is always shape-safe.
"""
start_latent: int
end_latent: int # exclusive
embed: torch.Tensor # (1, 512, 4096)
# ---------------------------------------------------------------------------
# Conditioning helper
# ---------------------------------------------------------------------------
@dataclass
class Condition:
"""Single ComfyUI conditioning entry: a prompt embedding plus extra dict."""
embed: torch.Tensor
extra: dict[str, Any]
def to_comfy(self) -> list:
"""Return the native ComfyUI representation ``[tensor, dict]``."""
return [self.embed, self.extra]
@dataclass
class Conditioning:
"""List-compatible wrapper around ComfyUI's ``[[tensor, dict], ...]`` format.
Nodes still return the underlying list on the wire so ComfyUI and other
custom nodes can consume the output; internally we use this class to avoid
magic indices like ``positive[0][1]``.
"""
items: list[Condition]
@classmethod
def from_comfy(cls, comfy_cond: list | Conditioning | None) -> "Conditioning":
"""Build a ``Conditioning`` helper from a native ComfyUI list."""
if comfy_cond is None:
return cls([])
if isinstance(comfy_cond, Conditioning):
return cls(list(comfy_cond.items))
parsed = []
for entry in comfy_cond:
if isinstance(entry, Condition):
parsed.append(entry)
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
parsed.append(Condition(embed=entry[0], extra=dict(entry[1] or {})))
else:
raise ValueError(f"Invalid conditioning entry: {entry!r}")
return cls(parsed)
def to_comfy(self) -> list[list]:
"""Return the native ComfyUI representation."""
return [item.to_comfy() for item in self.items]
# -- list-like interface ------------------------------------------------
def __len__(self) -> int:
return len(self.items)
def __iter__(self) -> Iterator[Condition]:
return iter(self.items)
def __getitem__(self, index: int | slice):
result = self.items[index]
if isinstance(result, list):
return Conditioning(result)
return result
def __bool__(self) -> bool:
return bool(self.items)
# -- convenience accessors ----------------------------------------------
@property
def first(self) -> Condition | None:
"""Return the first conditioning entry, or ``None`` if empty."""
return self.items[0] if self.items else None
@property
def embed(self) -> torch.Tensor | None:
"""Return the embedding of the first entry."""
first = self.first
return first.embed if first is not None else None
@property
def extra(self) -> dict[str, Any] | None:
"""Return the extra dict of the first entry."""
first = self.first
return first.extra if first is not None else None
def get_extra(self, key: str, default: Any = None) -> Any:
"""Read a value from the first entry's extra dict."""
extra = self.extra
if extra is None:
return default
return extra.get(key, default)
def set_extra(self, key: str, value: Any) -> "Conditioning":
"""Set a value on every entry's extra dict (in-place)."""
for item in self.items:
item.extra[key] = value
return self
def with_extra(self, **values) -> "Conditioning":
"""Return a new ``Conditioning`` with merged extra dicts."""
new_items = []
for item in self.items:
merged = dict(item.extra)
merged.update(values)
new_items.append(Condition(embed=item.embed, extra=merged))
return Conditioning(new_items)
def copy(self) -> "Conditioning":
"""Shallow copy: new ``Condition`` objects sharing the same tensors."""
return Conditioning([
Condition(embed=item.embed, extra=dict(item.extra))
for item in self.items
])