Adds an `amplify_reference` option (default on): the head reference is attached to the positive conditioning only while the guide stays on both, so ComfyUI's CFG amplifies the identity (approximates Bernini's omega_img guidance). Use CFG ~3-5 and disable LightX2V. Bump to 1.5.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
6.7 KiB
Python
164 lines
6.7 KiB
Python
"""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",
|
|
}
|