Files
alisson-anjos-ComfyUI-BFSNodes/ltx_multiple_controls.py
Claude 1936d5dd5e Add the sidecar layout to the LTX conditioning nodes (1.29.0)
The trainer has had four reference layouts for a while — overlap, st_drc, strata
and sidecar — but the nodes only ever exposed three. Training a LoRA with the
reference in sidecar therefore had no matching inference path, which is how you
end up running a checkpoint in a layout it was never trained in.

sidecar places the reference beside the target on the W axis, vertically centred
on it, and stretches its T span to cover the target's whole clip, so an object
reference stays available for every frame instead of sitting at one instant. It
is the layout the replace_refedit recipe validated on 2.3 for exactly this role.

Mirrors ltx_core.conditioning.reference_layout, adapted to ComfyUI's [B, 3, N]
coordinate tensor instead of the trainer's [B, 3, N, 2] patch bounds, and takes
sidecar_margin_pixels like the trainer does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:56:18 +00:00

309 lines
20 KiB
Python

"""LTX Multiple Controls — single-node version of chaining several LTXIdentityOverlapConditioning
(LTX Identity Transfer) calls for a guide (motion/structure) + mask (replacement region) +
identity (appearance) recipe, e.g. character-replace-in-driving-video training.
WHY THIS NODE EXISTS: LTXIdentityOverlapConditioning stores its reference specs in
model.model_options["transformer_options"]["_id_ref_specs"], and each call OVERWRITES that key
with its own specs list rather than merging with whatever the upstream node already put there.
Chaining N of those nodes therefore only keeps the LAST node's reference active at inference —
the earlier ones (e.g. the guide) silently have ZERO effect despite the graph looking correct.
This node builds every slot's ref_specs in ONE apply() call so they all end up in the same list.
Each slot (guide/mask/identity/identity_mask) is fully independent: its own source_id,
phase_scale, layout, ref_resize_mode -- no slot is hardcoded to a fixed layout/phase, only
given sane defaults that match the CURRENT trained recipe (scail2v2_r128_v13): guide=0 and
mask=0 (both => no RoPE phase tag, same as source_phase=false at train time -- they must stay
positionally overlapped with the target frame-by-frame); identity=2 (=> tagged, same as
source_phase=true, since it has no positional correspondence with the target). identity_mask
is unused by v13 (ref_mask was dropped from the recipe entirely) -- leave it unconnected.
v13's key fix vs earlier recipes: `guide_video` must have the replacement region painted BLACK
(an inpainting hole) before the model sees it, otherwise it just copies guide's own person into
the masked region and never uses `identity_image` at all -- confirmed as the root cause of a
long stretch of "identity ignored" results earlier in training. This node does that
automatically (`auto_mask_guide`, on by default) using `mask_video`'s white region -- do NOT
also pre-mask guide upstream or the hole will double-apply harmlessly but wastefully.
mask_video is now a plain WHITE-silhouette-on-black binary mask (NOT colored) -- an earlier
per-identity color-coded mask design caused the model to leak the mask's own hue into the
generated clothing (confirmed via direct pixel inspection during training); white/black avoids
that since it carries no color information for the VAE to latch onto.
"""
import logging
import torch
from . import ltx_identity_overlap as _ido
from .ltx_identity_overlap import (
_anchored_crop_resize,
_draw_crop_overlay,
_find_ltxv,
_install_patches,
_letterbox_resize,
)
from .reference_temporal_offset import (
DEFAULT_REFERENCE_TEMPORAL_OFFSET_LATENTS,
reference_temporal_offset_input,
)
log = logging.getLogger("LTXMultipleControls")
_LAYOUT_CHOICES = ["overlap", "st_drc", "strata", "sidecar"]
_RESIZE_CHOICES = ["match_target", "match_target_letterbox", "native_resolution"]
def _encode_ref(vae, latent, img, ref_resize_mode, crop_anchor, w_sf, h_sf, downscale_factor=1):
"""Resize (per ref_resize_mode/crop_anchor) + VAE-encode ONE reference IMAGE batch
([N,H,W,C], typically a whole video's frames). Mirrors LTXIdentityOverlapConditioning's
own _encode_one so behavior is byte-identical per slot.
downscale_factor>1: encode at target_size/downscale_factor instead of full target size
(mirrors the trainer's reference_downscale_factor -- fewer tokens/cheaper VRAM for a
condition that doesn't need full detail, e.g. guide/mask). Snapped to a multiple of
w_sf/h_sf (must stay divisible by the VAE's own spatial downscale, same constraint as
training's "must be divisible by 32" check)."""
import comfy.utils
if ref_resize_mode == "native_resolution":
_, src_h, src_w, _ = img.shape
tgt_w = max(w_sf, round(src_w / w_sf) * w_sf)
tgt_h = max(h_sf, round(src_h / h_sf) * h_sf)
else:
_, _, _, lat_h, lat_w = latent["samples"].shape
tgt_w, tgt_h = lat_w * w_sf, lat_h * h_sf
if downscale_factor != 1:
tgt_w = max(w_sf, round(tgt_w / downscale_factor / w_sf) * w_sf)
tgt_h = max(h_sf, round(tgt_h / downscale_factor / h_sf) * h_sf)
_, src_h0, src_w0, _ = img.shape
crop_box = (0, 0, src_w0, src_h0)
if ref_resize_mode == "match_target_letterbox":
ref_px = _letterbox_resize(img, tgt_w, tgt_h)[:, :, :, :3]
elif ref_resize_mode == "match_target" and crop_anchor != "center":
ref_px, crop_box = _anchored_crop_resize(img, tgt_w, tgt_h, anchor=crop_anchor)
ref_px = ref_px[:, :, :, :3]
else:
ref_px = comfy.utils.common_upscale(img.movedim(-1, 1), tgt_w, tgt_h, "bilinear", "center").movedim(1, -1)[:, :, :, :3]
if ref_resize_mode == "match_target":
_, crop_box = _anchored_crop_resize(img[:1], tgt_w, tgt_h, anchor="center")
ref_lat = vae.encode(ref_px)
overlay = _draw_crop_overlay(img[:1], crop_box)
return ref_lat, ref_px[:1].clone(), overlay, crop_box, src_w0, src_h0
class LTXMultipleControls:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"model": ("MODEL",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"vae": ("VAE",),
"latent": ("LATENT",),
}, "optional": {
"guide_video": ("IMAGE", {"tooltip": "Motion/structure driving video frames (IMAGE batch, e.g. from "
"GetVideoComponents). Leave unconnected to skip the guide slot. Gets auto-masked "
"black in the replacement region (see auto_mask_guide) unless disabled."}),
"guide_source_id": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 8.0, "step": 1.0,
"tooltip": "v13 recipe default: 0 (no RoPE phase tag at all -- must stay "
"source_phase=false so the guide's positions line up with the target "
"frame-by-frame). Non-zero only for older/experimental checkpoints."}),
"guide_phase_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.1}),
"guide_layout": (_LAYOUT_CHOICES, {"default": "overlap"}),
"guide_ref_resize_mode": (_RESIZE_CHOICES, {"default": "match_target"}),
"guide_downscale_factor": ("INT", {"default": 1, "min": 1, "max": 8,
"tooltip": "Encode at target_size/N instead of full res (cheaper VRAM). Must match "
"whatever the checkpoint was trained with -- 1 for the no-downscale "
"recipes, 2 for the earlier downscaled-guide recipes. Wrong value = "
"positions the model never learned, not just a quality hit."}),
"mask_video": ("IMAGE", {"tooltip": "Per-frame replacement-region mask, same frame count/alignment "
"as guide_video -- v13 expects a plain WHITE-on-black binary silhouette (person "
"white, background black). Do NOT use a colored mask (see Color Mask node) -- "
"confirmed to leak hue into the generated clothing during training. Also drives "
"auto_mask_guide's black-out of guide_video. Leave unconnected to skip."}),
"mask_source_id": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 8.0, "step": 1.0,
"tooltip": "v13 recipe default: 0 (same non-tagged group as guide -- both need exact "
"positional correspondence with the target). Non-zero only for older/"
"experimental checkpoints."}),
"mask_phase_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.1}),
"mask_layout": (_LAYOUT_CHOICES, {"default": "overlap"}),
"mask_ref_resize_mode": (_RESIZE_CHOICES, {"default": "match_target"}),
"mask_downscale_factor": ("INT", {"default": 1, "min": 1, "max": 8,
"tooltip": "Same as guide_downscale_factor -- match the checkpoint's training recipe."}),
"auto_mask_guide": ("BOOLEAN", {"default": True,
"tooltip": "v13 requirement: paints guide_video BLACK wherever mask_video is white, "
"before encoding -- an inpainting hole, so the model can't just copy "
"guide's own person into the replacement region and must pull identity "
"from identity_image instead (matches how the training data was built). "
"Turn off only if guide_video is already pre-masked upstream."}),
"mask_white_thresh": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "auto_mask_guide only -- mask_video pixels with mean brightness >= this "
"(0-1 scale) count as foreground (person) and get blacked out in guide."}),
"identity_image": ("IMAGE", {"tooltip": "Appearance reference (face/character), no positional "
"correspondence with the target needed -- this is what fills auto_mask_guide's hole. "
"Leave unconnected to skip."}),
"identity_source_id": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 8.0, "step": 1.0,
"tooltip": "v13 recipe default: 2. Nonzero = tagged with its own RoPE phase (matches "
"source_phase=true, since identity has no positional correspondence with "
"the target)."}),
"identity_phase_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.1}),
"identity_layout": (_LAYOUT_CHOICES, {"default": "overlap"}),
"identity_ref_resize_mode": (_RESIZE_CHOICES, {"default": "native_resolution"}),
"identity_downscale_factor": ("INT", {"default": 1, "min": 1, "max": 8,
"tooltip": "Same as guide_downscale_factor -- match the checkpoint's training recipe."}),
"identity_mask_image": ("IMAGE", {"tooltip": "scail2-style color-pointer marker for the identity "
"slot (small flat color dot/blob, NOT a body silhouette -- a body-shaped mask here "
"would re-inject a competing pose signal, exactly the bug the flat-marker design "
"fixes on the training side). Same source_id/phase as identity_image by default "
"(they're trained as one group) -- override identity_mask_source_id if your "
"checkpoint used a different grouping. Leave unconnected to skip."}),
"identity_mask_source_id": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 8.0, "step": 1.0,
"tooltip": "-1 = inherit identity_source_id/identity_phase_scale (matches training, "
"where ref+ref_mask share one group). Set >=0 to give it its own separate phase."}),
"identity_mask_phase_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.1}),
"identity_mask_layout": (_LAYOUT_CHOICES, {"default": "overlap"}),
"identity_mask_ref_resize_mode": (_RESIZE_CHOICES, {"default": "native_resolution"}),
"identity_mask_downscale_factor": ("INT", {"default": -1, "min": -1, "max": 8,
"tooltip": "-1 = inherit identity_downscale_factor. Same meaning otherwise as "
"guide_downscale_factor -- match the checkpoint's training recipe."}),
"crop_anchor": (["center", "top", "bottom", "left", "right"], {"default": "center",
"tooltip": "Shared by all slots using ref_resize_mode=match_target with a mismatched "
"aspect ratio -- which part of the source survives the crop."}),
"reference_guidance_scale": ("FLOAT", {"default": 1.0, "min": 1.0, "max": 10.0, "step": 0.1,
"tooltip": "ST-DRC-style reference-CFG applied to the WHOLE combined reference set "
"(all active slots together). 1.0 = off."}),
"debug_log": ("BOOLEAN", {"default": False}),
# Reference-only despite being appended here for saved-workflow widget stability.
"identity_temporal_offset_latents": reference_temporal_offset_input(),
}}
RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "LATENT", "STRING")
RETURN_NAMES = ("model", "positive", "negative", "latent", "debug")
FUNCTION = "apply"
CATEGORY = "LTX/identity"
DESCRIPTION = ("Guide + mask + identity + identity_mask reference conditioning in ONE node call, so all "
"active slots' RoPE reference specs actually combine (chaining LTXIdentityOverlapConditioning "
"nodes does NOT -- each call overwrites the previous one's specs). Every slot is optional and "
"its own source_id/phase_scale/layout/ref_resize_mode -- nothing is hardcoded, only defaulted "
"to match scail2v2_r128_v13 (guide=0, mask=0, both no-phase; identity=2, phased). guide_video "
"is auto-masked black in the replacement region (auto_mask_guide) to match how v13 was "
"trained -- this is the fix that made identity transfer actually work. identity_mask is "
"unused by v13 (leave unconnected); kept for older checkpoints.")
def apply(self, model, positive, negative, vae, latent,
guide_video=None, guide_source_id=0.0, guide_phase_scale=1.0,
guide_layout="overlap", guide_ref_resize_mode="match_target", guide_downscale_factor=1,
mask_video=None, mask_source_id=0.0, mask_phase_scale=1.0,
mask_layout="overlap", mask_ref_resize_mode="match_target", mask_downscale_factor=1,
auto_mask_guide=True, mask_white_thresh=0.5,
identity_image=None, identity_source_id=2.0, identity_phase_scale=1.0,
identity_layout="overlap", identity_ref_resize_mode="native_resolution", identity_downscale_factor=1,
identity_temporal_offset_latents=DEFAULT_REFERENCE_TEMPORAL_OFFSET_LATENTS,
identity_mask_image=None, identity_mask_source_id=-1.0, identity_mask_phase_scale=1.0,
identity_mask_layout="overlap", identity_mask_ref_resize_mode="native_resolution",
identity_mask_downscale_factor=-1,
crop_anchor="center", reference_guidance_scale=1.0, debug_log=False):
import comfy.samplers
import comfy.utils
_ido._DEBUG_ENABLED = bool(debug_log)
m = model.clone()
ltxv = _find_ltxv(m)
_, w_sf, h_sf = vae.downscale_index_formula
if guide_video is not None and mask_video is not None and auto_mask_guide:
mg = mask_video
if mg.shape[1:3] != guide_video.shape[1:3]:
mg = comfy.utils.common_upscale(
mg.movedim(-1, 1), guide_video.shape[2], guide_video.shape[1], "nearest-exact", "center",
).movedim(1, -1)
if mg.shape[0] != guide_video.shape[0]:
idx = torch.linspace(0, mg.shape[0] - 1, guide_video.shape[0]).round().long().clamp(max=mg.shape[0] - 1)
mg = mg[idx]
fg = mg.mean(dim=-1, keepdim=True) >= mask_white_thresh
guide_video = torch.where(fg, torch.zeros_like(guide_video), guide_video)
# -1 = inherit the identity slot's own grouping (matches training: ref+ref_mask share
# one source_id/phase_scale by default).
if identity_mask_source_id < 0:
identity_mask_source_id = identity_source_id
identity_mask_phase_scale = identity_phase_scale
if identity_mask_downscale_factor < 0:
identity_mask_downscale_factor = identity_downscale_factor
slots = [
("guide", guide_video, guide_source_id, guide_phase_scale, guide_layout, guide_ref_resize_mode, guide_downscale_factor, 0),
("mask", mask_video, mask_source_id, mask_phase_scale, mask_layout, mask_ref_resize_mode, mask_downscale_factor, 0),
("identity", identity_image, identity_source_id, identity_phase_scale, identity_layout,
identity_ref_resize_mode, identity_downscale_factor, identity_temporal_offset_latents),
("identity_mask", identity_mask_image, identity_mask_source_id, identity_mask_phase_scale,
identity_mask_layout, identity_mask_ref_resize_mode, identity_mask_downscale_factor, 0),
]
ref_specs = []
summary = []
for name, img, source_id, phase_scale, layout, resize_mode, downscale_factor, temporal_offset in slots:
if img is None:
continue
ref_lat, _px, _overlay, crop_box, src_w0, src_h0 = _encode_ref(
vae, latent, img, resize_mode, crop_anchor, w_sf, h_sf, downscale_factor=downscale_factor)
seg_value = float(source_id) * float(phase_scale)
ref_specs.append({"latent": ref_lat, "seg_value": seg_value, "layout": layout,
"strata_slot": len(ref_specs), "downscale_factor": downscale_factor,
"temporal_offset_latents": int(temporal_offset)})
dsf_note = f", downscale={downscale_factor}x" if downscale_factor != 1 else ""
offset_note = f", ref_t_offset={int(temporal_offset)} latent" if name == "identity" else ""
summary.append(
f"{name}: {img.shape[0]}f {src_w0}x{src_h0}px -> {layout}, seg={seg_value:g}, "
f"mode={resize_mode}{dsf_note}{offset_note}"
)
if not ref_specs:
log.warning("LTXMultipleControls: no slots connected -- passing through unchanged.")
return (m, positive, negative, latent, "LTX Multiple Controls: no active slots (nothing connected).")
_install_patches(ltxv)
ltxv._id_rope_theta = 10000.0
m.model_options = dict(m.model_options)
to = dict(m.model_options.get("transformer_options", {}))
to["_id_ref_specs"] = ref_specs
m.model_options["transformer_options"] = to
if reference_guidance_scale != 1.0:
noref_to = dict(to)
noref_to.pop("_id_ref_specs", None)
ref_scale = float(reference_guidance_scale)
def _ref_cfg_function(args):
cond = args["cond"]
uncond = args["uncond"]
cond_scale = args["cond_scale"]
denoised = uncond + (cond - uncond) * cond_scale
noref_model_options = dict(args["model_options"])
noref_model_options["transformer_options"] = noref_to
(noref_pred,) = comfy.samplers.calc_cond_batch(
args["model"], [args["input_cond"]], args["input"], args["timestep"], noref_model_options,
)
noref_denoised = args["input"] - noref_pred
denoised = denoised + (ref_scale - 1.0) * (cond - noref_denoised)
return denoised
m.set_model_sampler_cfg_function(_ref_cfg_function, disable_cfg1_optimization=True)
guide_masked = guide_video is not None and mask_video is not None and auto_mask_guide
dbg = (
"=== LTX Multiple Controls ===\n"
f"{len(ref_specs)} active slot(s):\n " + "\n ".join(summary) + "\n"
f"auto_mask_guide: {'applied' if guide_masked else 'off/skipped'}\n"
f"reference-CFG: {'off' if reference_guidance_scale == 1.0 else f'ON, scale={reference_guidance_scale}'}\n"
"Set LTX_IDOVERLAP_DEBUG=1 for per-step shape logs. Connect negative + CFG 3-5, no LightX2V."
)
log.info("\n" + dbg)
return (m, positive, negative, latent, dbg)
NODE_CLASS_MAPPINGS = {"LTXMultipleControls": LTXMultipleControls}
NODE_DISPLAY_NAME_MAPPINGS = {"LTXMultipleControls": "LTX Multiple Controls"}