fix: restore backward compat, do stacked refs via batch not new inputs
v1.13.0 broke existing workflows: renamed the required reference_face input to reference_image, and deleted the identity_projector/id_strength /arcface_mode ArcFace inputs entirely. This repo has real users on the current node -- reverting both: reference_face keeps its name, ArcFace is back exactly as it was (still only used for the primary/1st reference). Multiple stacked references (layout='strata') are now driven by feeding a BATCH of images into the existing reference_face input (e.g. via an Image Batch node) instead of a second fixed reference_face_2/source_id_2 pair of inputs -- a single image (the old, default case) behaves exactly as before; N images each become their own reference block, source_id auto-incrementing per index (1st keeps source_id as-is, 2nd gets +1, ...). ref_preview/crop_overlay outputs now return one image per batch entry instead of only the first, so all stacked refs are visible. Also fixes a real bug in the reverted 1.13.0 approach the user hit while testing (second reference silently not applying) -- the model patch now tracks a list of (offset, length, source_phase) blocks end to end (process_input concat + prepare_pe rotation), the same proven pattern already used by ltx_identity_multiangle.py, instead of a single ref latent/seg_value pair that only the last-installed spec could occupy.
This commit is contained in:
+110
-62
@@ -149,7 +149,8 @@ def _rotate_ref_block(pe, start, length, seg_value, theta=10000.0):
|
||||
"""Rotate RoPE freqs of tokens [start:start+length] by phase = seg_value * theta^(-d/L).
|
||||
pe = (cos, sin, [split_flag]). cos/sin shape [..., T, L] or [B,H,T,L]. Returns new pe tuple.
|
||||
One reference block's own token range gets its own seg_value (source_id*phase_scale) --
|
||||
callers loop this once per stacked reference so each keeps its own RoPE phase tag.
|
||||
callers loop this once per stacked reference so each keeps its own RoPE phase tag. With a
|
||||
single reference (the common case), this is exactly the old "last ref_len tokens" rotation.
|
||||
"""
|
||||
if length <= 0 or seg_value == 0.0:
|
||||
return pe
|
||||
@@ -254,9 +255,10 @@ def _draw_crop_overlay(ref_img, box):
|
||||
|
||||
# Seconds reserved per numbered strata slot -- MUST match ltx_trainer's
|
||||
# training_strategies.tass.STRATA_SLOT_WIDTH (0.5) for a strata-trained checkpoint's RoPE
|
||||
# convention to line up at inference. Slot 0 (first ref) lands at target_max_t + this value,
|
||||
# slot 1 (second ref) at target_max_t + 2*this value, etc. -- dynamic/target-relative, same
|
||||
# as st_drc's own shift, so it stays correct for whatever video length is actually generated.
|
||||
# convention to line up at inference. Slot 0 (1st ref in the batch) lands at
|
||||
# target_max_t + this value, slot 1 (2nd ref) at target_max_t + 2*this value, etc. --
|
||||
# dynamic/target-relative, same as st_drc's own shift, so it stays correct for whatever
|
||||
# video length is actually generated.
|
||||
STRATA_SLOT_WIDTH = 0.5
|
||||
|
||||
|
||||
@@ -300,10 +302,10 @@ def _install_patches(ltxv):
|
||||
if orig_forward_internal is not None:
|
||||
def _forward_capture_fps(self, x, timestep, context, attention_mask, frame_rate=25,
|
||||
transformer_options={}, keyframe_idxs=None, denoise_mask=None, **kwargs):
|
||||
# _forward calls _process_input BEFORE _prepare_positional_embeddings (which is
|
||||
# the only other place frame_rate normally reaches) -- process_input needs it
|
||||
# EARLIER, to convert the seconds-scale STRATA_SLOT_WIDTH into this step's raw
|
||||
# pixel/frame units. Stash here so it's always current, never a step behind.
|
||||
# _forward calls _process_input BEFORE _prepare_positional_embeddings (the only
|
||||
# other place frame_rate normally reaches) -- process_input needs it EARLIER, to
|
||||
# convert the seconds-scale STRATA_SLOT_WIDTH into this step's raw pixel/frame
|
||||
# units. Stash here so it's always current, never a step behind.
|
||||
self._id_frame_rate = float(frame_rate)
|
||||
return orig_forward_internal(
|
||||
x, timestep, context, attention_mask, frame_rate=frame_rate,
|
||||
@@ -373,7 +375,7 @@ def _install_patches(ltxv):
|
||||
xx, pix = vx, vco
|
||||
return xx, pix, add
|
||||
except Exception as e:
|
||||
_dbg("ERROR process_input:", repr(e), "| out", _shape(out), "| ref_specs", len(ref_specs) if ref_specs else 0)
|
||||
_dbg("ERROR process_input:", repr(e), "| out", _shape(out), "| n_refs", len(ref_specs) if ref_specs else 0)
|
||||
raise
|
||||
|
||||
def prepare_timestep(self, timestep, batch_size, hidden_dtype, **kw):
|
||||
@@ -492,18 +494,39 @@ def _install_patches(ltxv):
|
||||
class LTXIdentityOverlapConditioning:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# Populate the projector dropdown from the loras folder (+ "None" = no projector).
|
||||
try:
|
||||
import folder_paths
|
||||
proj_choices = ["None"] + folder_paths.get_filename_list("loras")
|
||||
except Exception:
|
||||
proj_choices = ["None"]
|
||||
return {"required": {
|
||||
"model": ("MODEL",),
|
||||
"positive": ("CONDITIONING",),
|
||||
"negative": ("CONDITIONING",),
|
||||
"vae": ("VAE",),
|
||||
"latent": ("LATENT",),
|
||||
"reference_image": ("IMAGE", {
|
||||
"reference_face": ("IMAGE", {
|
||||
"tooltip": "Reference to copy into the generation -- any subject (object, animal, "
|
||||
"character, person...), not just a face."}),
|
||||
"character, person...), not just a face; name kept for backward "
|
||||
"compatibility with existing workflows. Accepts a BATCH of N images (use "
|
||||
"an Image Batch node to combine several) for checkpoints trained on "
|
||||
"multiple STACKED references (layout='strata') -- each image in the batch "
|
||||
"becomes its own reference block with source_id = source_id + its index "
|
||||
"(0-based: 1st image keeps 'source_id' as-is, 2nd gets source_id+1, ...). "
|
||||
"A single image (the default/old behavior) works exactly as before."}),
|
||||
"identity_projector": (proj_choices, {"default": "None",
|
||||
"tooltip": "ArcFace projector .safetensors (from models/loras). 'None' = overlap only "
|
||||
"(the projector is a weak channel; overlap latent carries identity)."}),
|
||||
"source_id": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 8.0, "step": 1.0,
|
||||
"tooltip": "source_phase segment id (training used 2). 0 = no phase."}),
|
||||
"phase_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.1}),
|
||||
"id_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 50.0, "step": 0.5,
|
||||
"tooltip": "Multiplies the ArcFace projector tokens (only when a projector is selected). "
|
||||
"Weak channel; push high (5-20) to test, very high may add artifacts."}),
|
||||
"arcface_mode": (["auto_adjust", "as_is", "disable"], {"default": "auto_adjust",
|
||||
"tooltip": "auto_adjust: retry face detection with zoom-out/upscale, skip tokens if none. "
|
||||
"as_is: detect on the image only. disable: skip ArcFace, use only the overlap latent."}),
|
||||
"ref_resize_mode": (["match_target", "match_target_letterbox", "native_resolution"], {"default": "match_target",
|
||||
"tooltip": "match_target: resize ref to the OUTPUT video's pixel size via a CENTER-CROP then "
|
||||
"resize (old single-face-crop recipes — ref resolution never mattered, but this "
|
||||
@@ -537,21 +560,12 @@ class LTXIdentityOverlapConditioning:
|
||||
"only by source_phase -- what every checkpoint so far was trained with). 'st_drc' "
|
||||
"shifts the WHOLE reference block past the target's coordinate extent on every "
|
||||
"axis (non-overlapping region). 'strata' shifts ONLY the temporal axis to a slot "
|
||||
"past the target's own length, one slot per connected reference (reference_image -> "
|
||||
"slot 0, reference_image_2 -> slot 1), leaving H/W overlapping the target -- this is "
|
||||
"the 'stacked references' convention (ltx_trainer TASS strata layout, multiple "
|
||||
"SEPARATE ref images under the same source_id family). Only use whichever layout "
|
||||
"the loaded checkpoint was actually trained with -- using the wrong one is not a "
|
||||
"quality hit, it's a coordinate convention the model never learned, so identity "
|
||||
"transfer likely won't work at all."}),
|
||||
"reference_image_2": ("IMAGE", {
|
||||
"tooltip": "OPTIONAL second reference image (source_id_2, default 3). Only meaningful "
|
||||
"for checkpoints trained on multiple STACKED references under layout='strata' "
|
||||
"(e.g. phantom_stacked_r128*) -- leave disconnected for single-reference "
|
||||
"checkpoints (the overlap/st_drc convention every earlier checkpoint used)."}),
|
||||
"source_id_2": ("FLOAT", {"default": 3.0, "min": 0.0, "max": 8.0, "step": 1.0,
|
||||
"tooltip": "source_phase segment id for reference_image_2 (training used 3). Only used "
|
||||
"when reference_image_2 is connected."}),
|
||||
"past the target's own length -- one slot per image in the reference_face batch "
|
||||
"(1st image -> slot 0, 2nd -> slot 1, ...), leaving H/W overlapping the target; "
|
||||
"this is the 'stacked references' convention (ltx_trainer TASS strata layout). "
|
||||
"Only use whichever layout the loaded checkpoint was actually trained with -- "
|
||||
"using the wrong one is not a quality hit, it's a coordinate convention the "
|
||||
"model never learned, so identity transfer likely won't work at all."}),
|
||||
"reference_guidance_scale": ("FLOAT", {"default": 1.0, "min": 1.0, "max": 10.0, "step": 0.1,
|
||||
"tooltip": "ST-DRC-style reference-CFG (arxiv 2606.02441). 1.0 = off (identical to "
|
||||
"before this input existed). >1.0 adds a THIRD forward pass per step with "
|
||||
@@ -566,37 +580,35 @@ class LTXIdentityOverlapConditioning:
|
||||
RETURN_NAMES = ("model", "positive", "negative", "latent", "debug", "ref_preview", "crop_overlay")
|
||||
FUNCTION = "apply"
|
||||
CATEGORY = "LTX/identity"
|
||||
DESCRIPTION = ("100%-exact overlap/st_drc/strata + source_phase reference (as trained) via a model patch. "
|
||||
"Reference is separate tokens (NOT I2V), any subject -- not just a face. Optional second "
|
||||
"reference for checkpoints trained on stacked (layout='strata') references. "
|
||||
"Load LoRA on MODEL first. ref_preview/crop_overlay outputs show exactly what "
|
||||
"gets encoded and, for match_target, what part of the reference survives the crop "
|
||||
"(green box) vs gets discarded.")
|
||||
DESCRIPTION = ("100%-exact overlap+source_phase reference (as trained) via a model patch, "
|
||||
"plus ArcFace projector tokens. Ref is separate tokens (NOT I2V). Load LoRA on MODEL first. "
|
||||
"ref_preview/crop_overlay outputs (v1.10.13+) show exactly what gets encoded and, for "
|
||||
"match_target, what part of the reference survives the crop (green box) vs gets discarded.")
|
||||
|
||||
def apply(self, model, positive, negative, vae, latent, reference_image,
|
||||
source_id=2.0, phase_scale=1.0,
|
||||
ref_resize_mode="match_target", debug_log=False,
|
||||
crop_anchor="center", layout="overlap", reference_guidance_scale=1.0,
|
||||
reference_image_2=None, source_id_2=3.0):
|
||||
def apply(self, model, positive, negative, vae, latent, reference_face,
|
||||
identity_projector="None", source_id=2.0, phase_scale=1.0, id_strength=1.0,
|
||||
arcface_mode="auto_adjust", ref_resize_mode="match_target", debug_log=False,
|
||||
crop_anchor="center", layout="overlap", reference_guidance_scale=1.0):
|
||||
import comfy.samplers
|
||||
import comfy.utils
|
||||
|
||||
global _DEBUG_ENABLED
|
||||
_DEBUG_ENABLED = bool(debug_log)
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
m = model.clone()
|
||||
ltxv = _find_ltxv(m)
|
||||
|
||||
_, w_sf, h_sf = vae.downscale_index_formula
|
||||
n_refs = reference_face.shape[0]
|
||||
|
||||
def _encode_ref(ref_img):
|
||||
"""Resize (per ref_resize_mode/crop_anchor) + VAE-encode one reference image.
|
||||
Shared by reference_image and the optional reference_image_2 -- same resize policy
|
||||
for both, only the source pixels differ."""
|
||||
def _encode_one(img1):
|
||||
"""Resize (per ref_resize_mode/crop_anchor) + VAE-encode ONE reference image
|
||||
([1,H,W,C] slice). Byte-identical to the pre-batch code path when n_refs == 1."""
|
||||
if ref_resize_mode == "native_resolution":
|
||||
# Encode at the ref image's OWN resolution (rounded to nearest 32px),
|
||||
# independent of the output video size -- matches training when the ref used
|
||||
# a fixed/own bucket.
|
||||
_, src_h, src_w, _ = ref_img.shape
|
||||
_, src_h, src_w, _ = img1.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:
|
||||
@@ -605,33 +617,33 @@ class LTXIdentityOverlapConditioning:
|
||||
# a small face crop -- resolution never mattered there).
|
||||
_, _, _, lat_h, lat_w = latent["samples"].shape
|
||||
tgt_w, tgt_h = lat_w * w_sf, lat_h * h_sf
|
||||
_, src_h0, src_w0, _ = ref_img.shape
|
||||
_, src_h0, src_w0, _ = img1.shape
|
||||
crop_box = (0, 0, src_w0, src_h0) # default: "nothing cropped" (letterbox/native modes)
|
||||
if ref_resize_mode == "match_target_letterbox":
|
||||
ref_px = _letterbox_resize(ref_img, tgt_w, tgt_h)[:1, :, :, :3]
|
||||
ref_px = _letterbox_resize(img1, tgt_w, tgt_h)[:1, :, :, :3]
|
||||
elif ref_resize_mode == "match_target" and crop_anchor != "center":
|
||||
# non-default anchor: use the configurable-anchor crop (new in v1.10.13)
|
||||
ref_px, crop_box = _anchored_crop_resize(ref_img, tgt_w, tgt_h, anchor=crop_anchor)
|
||||
ref_px, crop_box = _anchored_crop_resize(img1, tgt_w, tgt_h, anchor=crop_anchor)
|
||||
ref_px = ref_px[:1, :, :, :3]
|
||||
else:
|
||||
# unchanged from before v1.10.13 -- exact original center-crop path, byte-identical
|
||||
ref_px = comfy.utils.common_upscale(ref_img.movedim(-1, 1), tgt_w, tgt_h, "bilinear", "center").movedim(1, -1)[:1, :, :, :3]
|
||||
ref_px = comfy.utils.common_upscale(img1.movedim(-1, 1), tgt_w, tgt_h, "bilinear", "center").movedim(1, -1)[:1, :, :, :3]
|
||||
if ref_resize_mode == "match_target":
|
||||
_, crop_box = _anchored_crop_resize(ref_img, tgt_w, tgt_h, anchor="center") # for the preview overlay only
|
||||
_, crop_box = _anchored_crop_resize(img1, tgt_w, tgt_h, anchor="center") # for the preview overlay only
|
||||
ref_lat = vae.encode(ref_px)
|
||||
return ref_lat, ref_px.clone(), crop_box
|
||||
overlay = _draw_crop_overlay(img1[:1], crop_box)
|
||||
return ref_lat, ref_px.clone(), overlay, crop_box, src_w0, src_h0
|
||||
|
||||
ref_lat, ref_preview, crop_box = _encode_ref(reference_image)
|
||||
_, src_h0, src_w0, _ = reference_image.shape
|
||||
crop_overlay = _draw_crop_overlay(reference_image[:1], crop_box)
|
||||
|
||||
has_ref2 = reference_image_2 is not None and getattr(reference_image_2, "shape", (0,))[0] > 0
|
||||
ref_specs = [{"latent": ref_lat, "seg_value": float(source_id) * float(phase_scale),
|
||||
"layout": layout, "strata_slot": 0}]
|
||||
if has_ref2:
|
||||
ref_lat2, _, _ = _encode_ref(reference_image_2)
|
||||
ref_specs.append({"latent": ref_lat2, "seg_value": float(source_id_2) * float(phase_scale),
|
||||
"layout": layout, "strata_slot": 1})
|
||||
ref_specs, ref_previews, crop_overlays = [], [], []
|
||||
for i in range(n_refs):
|
||||
ref_lat_i, ref_px_i, overlay_i, crop_box, src_w0, src_h0 = _encode_one(reference_face[i:i + 1])
|
||||
ref_specs.append({"latent": ref_lat_i, "seg_value": (float(source_id) + i) * float(phase_scale),
|
||||
"layout": layout, "strata_slot": i})
|
||||
ref_previews.append(ref_px_i)
|
||||
crop_overlays.append(overlay_i)
|
||||
ref_lat = ref_specs[0]["latent"] # for the debug string below (1st ref's shape)
|
||||
ref_preview = torch.cat(ref_previews, dim=0)
|
||||
crop_overlay = torch.cat(crop_overlays, dim=0)
|
||||
|
||||
_install_patches(ltxv)
|
||||
ltxv._id_rope_theta = 10000.0
|
||||
@@ -672,12 +684,48 @@ class LTXIdentityOverlapConditioning:
|
||||
|
||||
m.set_model_sampler_cfg_function(_ref_cfg_function, disable_cfg1_optimization=True)
|
||||
|
||||
# ArcFace projector tokens on the text context — fully OPTIONAL. Skipped when the
|
||||
# projector dropdown is 'None', arcface is disabled, or no face is detected. The
|
||||
# overlap latent carries the bulk of identity, so this is safe to skip. Only the
|
||||
# FIRST reference image (index 0, slot 0 -- the "primary"/face slot) drives it, same
|
||||
# as the single-image behavior before batched references existed.
|
||||
use_projector = identity_projector not in (None, "", "None")
|
||||
emb = _arcface_embed(reference_face[:1], mode=arcface_mode) if use_projector else None
|
||||
if not use_projector:
|
||||
arc_status = "no projector (overlap only)"
|
||||
elif arcface_mode == "disable":
|
||||
arc_status = "disabled"
|
||||
else:
|
||||
arc_status = "OK" if emb is not None else "NO FACE -> skipped (overlap only)"
|
||||
if emb is not None:
|
||||
path = identity_projector
|
||||
if not os.path.isabs(path):
|
||||
try:
|
||||
import folder_paths
|
||||
resolved = folder_paths.get_full_path("loras", identity_projector)
|
||||
if resolved:
|
||||
path = resolved
|
||||
except Exception:
|
||||
pass
|
||||
if not os.path.isabs(path) or not os.path.exists(path):
|
||||
for b in ("models/loras", "models", "."):
|
||||
if os.path.exists(os.path.join(b, identity_projector)):
|
||||
path = os.path.join(b, identity_projector); break
|
||||
projector = _load_projector(path, device)
|
||||
emb = emb.to(device=device, dtype=torch.float32).unsqueeze(0)
|
||||
with torch.no_grad():
|
||||
id_tok = projector(emb) * float(id_strength)
|
||||
unc = projector(torch.zeros(1, projector.in_dim, device=device))
|
||||
positive = _append_ctx_tokens(positive, id_tok)
|
||||
negative = _append_ctx_tokens(negative, unc)
|
||||
|
||||
seg_list = ", ".join(f"#{i}={s['seg_value']:g}" for i, s in enumerate(ref_specs))
|
||||
dbg = (
|
||||
"=== LTX Identity OVERLAP (exact) ===\n"
|
||||
f"ref latent: {list(ref_lat.shape)} (encoded at {ref_preview.shape[2]}x{ref_preview.shape[1]}px, "
|
||||
f"references: {n_refs} (encoded at {ref_preview.shape[2]}x{ref_preview.shape[1]}px each, "
|
||||
f"mode={ref_resize_mode}{f', crop_anchor={crop_anchor}' if ref_resize_mode == 'match_target' else ''}) "
|
||||
f"-> {layout} tokens, source_phase seg={float(source_id)*float(phase_scale)}\n"
|
||||
f"reference_image_2: {'connected, seg=' + str(float(source_id_2)*float(phase_scale)) + ' (slot 1)' if has_ref2 else 'not connected (single-ref)'}\n"
|
||||
f"-> {layout} tokens, source_phase seg per ref: {seg_list}\n"
|
||||
f"arcface: {arc_status} (mode={arcface_mode}) | id_strength={id_strength}\n"
|
||||
f"patches on {type(ltxv).__name__}: process_input/prepare_timestep/prepare_pe/process_output\n"
|
||||
f"crop preview: kept region {crop_box[2]}x{crop_box[3]}px of the {src_w0}x{src_h0}px reference "
|
||||
"-- see the ref_preview/crop_overlay IMAGE outputs to inspect what gets kept vs discarded.\n"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "bfsnodes"
|
||||
description = "Custom ComfyUI nodes including LTXV Edit Anything, Head-Swap/Anime2Real Bernini conditioning, the AMV Guide Builder, and LTX Identity Transfer (IPT2V reference + source-phase RoPE)."
|
||||
version = "1.13.0"
|
||||
version = "1.14.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"torch",
|
||||
|
||||
Reference in New Issue
Block a user