20260718 / custom_nodes /ComfyUI-BFSNodes /headswap_node.py
hipinis's picture
Upload 2196 files
a4816c6 verified
Raw
History Blame Contribute Delete
7.03 kB
"""Head-Swap Bernini Conditioning — standalone node for the BFS pack.
Self-contained: bundles the WanModel patch (vendored `bernini_patches.py`) that
makes the model consume `context_latents`. NO dependency on ComfyUI-RH-Bernini.
Fixed to our head-swap training case:
guide video -> context[0] -> source_id 1 (scene/body/motion kept)
head image -> context[1] -> source_id 2 (identity)
denoised output (source_id 0) = the empty latent
Sizes follow the inputs: output = guide video's native resolution (snapped to a
/16 grid), head reference = its own native resolution (snapped). Only `length`
(frame count) is a knob.
Classic ComfyUI API (NODE_CLASS_MAPPINGS) so it drops into any pack. In BFS's
__init__, either `from .headswap_node import NODE_CLASS_MAPPINGS as X; ...update`,
or merge NODE_CLASS_MAPPINGS / NODE_DISPLAY_NAME_MAPPINGS below.
"""
import logging
import torch
import comfy.model_management
import comfy.utils
import node_helpers
# Apply the bundled WanModel patch on import (idempotent; no-ops if core already
# supports Bernini context_latents).
try:
from .bernini_patches import apply_bernini_patches
except Exception: # pragma: no cover - flat import fallback
from bernini_patches import apply_bernini_patches
log = logging.getLogger("BFS.HeadSwapBernini")
try:
apply_bernini_patches()
except Exception as e:
log.warning("HeadSwap-Bernini: WanModel patch not applied: %s", e)
STRIDE = 16
def _snap(v, stride=STRIDE):
return max(stride, round(v / stride) * stride)
def _snap_frames(n):
# Wan VAE compresses time by 4 -> 4k+1 frame counts.
return max(1, ((max(1, int(n)) - 1) // 4) * 4 + 1)
def _encode_native(vae, frames):
"""VAE-encode [T,H,W,C] at native size, snapped to the /16 grid."""
h, w = frames.shape[1], frames.shape[2]
nh, nw = _snap(h), _snap(w)
if (nh, nw) != (h, w):
frames = comfy.utils.common_upscale(
frames[:, :, :, :3].movedim(-1, 1), nw, nh, "area", "disabled"
).movedim(1, -1)
return vae.encode(frames[:, :, :, :3]), nh, nw
def _tokens(lat):
_, _, t, h, w = lat.shape
return t * (h // 2) * (w // 2)
class HeadSwapBerniniConditioning:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"vae": ("VAE",),
"guide_video": ("IMAGE", {"tooltip": "Source video (scene/body/motion kept). Output size = this."}),
"head_image": ("IMAGE", {"tooltip": "Reference head/face image (identity). Crop to head only."}),
"length": ("INT", {"default": 73, "min": 1, "max": 1000, "step": 4,
"tooltip": "Frame count (snapped to 4k+1). Trained at 73."}),
"amplify_reference": ("BOOLEAN", {"default": True, "tooltip":
"ON: head reference goes on positive only, so the sampler's CFG amplifies "
"the identity (guide stays on both as a stable canvas). Use CFG ~3-5, no "
"LightX2V (CFG 1.0 = no amplification). OFF: reference on both (legacy, weaker)."}),
}
}
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT", "STRING")
RETURN_NAMES = ("positive", "negative", "latent", "debug")
FUNCTION = "execute"
CATEGORY = "BFS/video"
def execute(self, positive, negative, vae, guide_video, head_image, length, amplify_reference=True):
length = _snap_frames(length)
guide = guide_video[:length]
guide_lat, gh, gw = _encode_native(vae, guide) # source_id 1
head_lat, hh, hw = _encode_native(vae, head_image[:1]) # source_id 2
# positive always carries guide + head. With amplify_reference, the negative
# carries the guide ONLY (no head) -> CFG (cond-uncond) isolates and amplifies
# the identity, while the guide stays as a stable canvas on both sides. This
# approximates Bernini's omega_img reference guidance using ComfyUI's plain CFG.
ctx_pos = [guide_lat, head_lat]
ctx_neg = [guide_lat] if amplify_reference else [guide_lat, head_lat]
positive = node_helpers.conditioning_set_values(positive, {"context_latents": ctx_pos})
negative = node_helpers.conditioning_set_values(negative, {"context_latents": ctx_neg})
latent = torch.zeros(
[1, 16, ((length - 1) // 4) + 1, gh // 8, gw // 8],
device=comfy.model_management.intermediate_device(),
)
dbg = (
"=== Head-Swap Bernini Conditioning ===\n"
f"OUTPUT (source_id 0 / target): {gw}x{gh}, {length}f -> latent {tuple(latent.shape)}\n"
f"GUIDE (source_id 1, kept): native {guide_video.shape[2]}x{guide_video.shape[1]} -> {gw}x{gh} "
f"-> latent {tuple(guide_lat.shape)} (~{_tokens(guide_lat)} tokens)\n"
f"HEAD (source_id 2, identity):native {head_image.shape[2]}x{head_image.shape[1]} -> {hw}x{hh} "
f"-> latent {tuple(head_lat.shape)} (~{_tokens(head_lat)} tokens)\n"
f"amplify_reference: {amplify_reference} (positive ctx={len(ctx_pos)}, negative ctx={len(ctx_neg)})\n"
+ ("-> set CFG ~3-5 and DISABLE LightX2V (CFG 1.0 won't amplify).\n" if amplify_reference else "")
+ "Body of the head image bleeding in? crop head_image to head/shoulders only."
)
log.info("\n" + dbg)
return (positive, negative, {"samples": latent}, dbg)
class HeadSwapLoRADebug:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"model": ("MODEL",)}}
RETURN_TYPES = ("MODEL", "STRING")
RETURN_NAMES = ("model", "debug")
FUNCTION = "execute"
CATEGORY = "BFS/video"
def execute(self, model):
patches = getattr(model, "patches", {}) or {}
n_keys = len(patches)
total = sum(len(v) for v in patches.values())
block_keys = [k for k in patches if "blocks" in k]
sample = list(patches.keys())[:10]
dbg = (
"=== Head-Swap LoRA Debug ===\n"
f"patched weight keys: {n_keys}\n"
f"total patch entries: {total}\n"
f"keys touching transformer blocks: {len(block_keys)}\n"
+ ("!! ZERO patches -> LoRA not loaded or keys didn't match.\n" if n_keys == 0 else "")
+ "sample keys:\n" + ("\n".join(f" {k}" for k in sample) if sample else " (none)")
)
log.info("\n" + dbg)
return (model, dbg)
NODE_CLASS_MAPPINGS = {
"BFSHeadSwapBerniniConditioning": HeadSwapBerniniConditioning,
"BFSHeadSwapLoRADebug": HeadSwapLoRADebug,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BFSHeadSwapBerniniConditioning": "Head-Swap Bernini Conditioning",
"BFSHeadSwapLoRADebug": "Head-Swap LoRA Debug",
}