Files
olivv-cs-ComfyUI-FunPack/tests/test_identity_transfer.py
T
digital-garbageandClaude Sonnet 5 622e8ef747 Best-FaceID identity transfer: native source-phase RoPE port, no external node
Reverts the earlier ComfyUI-BFSNodes graph-splice approach (builder.py/timeline.py/
server.py scaffolding) in favor of porting the missing piece natively: FunPack's own
guide-attention path (LTXVAddGuide.add_keyframe_index + guide_attention_entries)
already places a reference image on the frame-0 grid exactly like BFSNodes' overlap
technique, and Continuity's existing "Identity pin" already applies one reference
face to every scene. The only real gap was the LoRA's trained-time source_phase RoPE
tag, added as a tag+strip patch on FunPackLTXAVSceneChainSampler scoped to the exact
guide-entry token range (never co-active mid_scene/carry/prior-scene guides sharing
the same keyframe_idxs tensor). New "Best-FaceID compatibility" + "Source-phase id"
knobs live under Engine Settings -> Experimental, alongside every other unvalidated
technique. LoRA loading itself reuses the existing generic Models-menu lora role.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 20:46:56 +03:00

240 lines
10 KiB
Python

"""Unit tests for the native Best-FaceID identity_transfer port.
Covers the three pieces added to FunPackLTXAVSceneChainSampler:
- _tag_last_guide_entry: best-effort marks the most-recently-appended
guide_attention_entry, never touching earlier entries.
- _apply_configured_guides: routes the phase tag ONLY to the guide entry the
editor marked identity_pin (never prior-scene/mid-scene/template guides that
happen to share the same call).
- _install_identity_phase / _strip_identity_phase / _rotate_identity_phase: the
tag+strip RoPE patch that reproduces the LoRA's trained-time source_phase
tag, scoped to the exact token range of the tagged guide (not the whole
guide-token tail, which may include co-active unrelated guides).
"""
import sys
import types
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
for _name in (
"comfy", "comfy.k_diffusion", "comfy.k_diffusion.sampling",
"comfy.model_sampling", "comfy.nested_tensor", "comfy.sample",
"comfy.samplers", "comfy.utils",
):
sys.modules.setdefault(_name, types.ModuleType(_name))
sys.modules["comfy.nested_tensor"].NestedTensor = object
import samplers # noqa: E402
def _sampler():
return samplers.FunPackLTXAVSceneChainSampler()
def _install_node_helpers():
"""node_helpers.conditioning_set_values, the same real implementation shape
_append_guide_attention_entry itself relies on (copy metadata dict, overwrite
the given keys) — reimplemented here so the test runs without a full ComfyUI env."""
mod = types.ModuleType("node_helpers")
def conditioning_set_values(conditioning, values=None, append=False):
values = values or {}
out = []
for t in conditioning:
n = [t[0], t[1].copy()]
for k, v in values.items():
n[1][k] = v
out.append(n)
return out
mod.conditioning_set_values = conditioning_set_values
sys.modules["node_helpers"] = mod
return mod
def _cond_with_entries(entries):
return [[torch.zeros(1, 1, 4), {"guide_attention_entries": list(entries)}]]
# ── _tag_last_guide_entry ──────────────────────────────────────────────────────
def test_tag_last_guide_entry_marks_only_newest_entry():
_install_node_helpers()
s = _sampler()
cond = _cond_with_entries([{"pre_filter_count": 4}, {"pre_filter_count": 8}])
tagged = s._tag_last_guide_entry(cond, "funpack_identity_phase", 2.0)
entries = tagged[0][1]["guide_attention_entries"]
assert entries[0].get("funpack_identity_phase") is None
assert entries[1]["funpack_identity_phase"] == 2.0
# Original untouched (conditioning_set_values copies, doesn't mutate in place).
assert "funpack_identity_phase" not in cond[0][1]["guide_attention_entries"][1]
def test_tag_last_guide_entry_noop_without_entries():
_install_node_helpers()
s = _sampler()
cond = [[torch.zeros(1, 1, 4), {}]]
out = s._tag_last_guide_entry(cond, "funpack_identity_phase", 2.0)
assert out is cond # unchanged object, nothing to tag
def test_tag_last_guide_entry_noop_without_node_helpers():
sys.modules.pop("node_helpers", None)
s = _sampler()
cond = _cond_with_entries([{"pre_filter_count": 4}])
out = s._tag_last_guide_entry(cond, "funpack_identity_phase", 2.0)
assert out is cond
# ── _apply_configured_guides phase-tag routing ─────────────────────────────────
def test_phase_tag_applies_only_to_identity_pin_entry(monkeypatch):
s = _sampler()
calls = []
def fake_append_media(chunk, filename, frame_idx, apply_at, strength, positive, negative, vae,
phase_tag=0.0):
calls.append((filename, phase_tag))
return chunk, positive, negative, 0, 0
monkeypatch.setattr(s, "_append_media_guide_at", fake_append_media)
guide_list = [
{"enabled": True, "source": "image", "media_ref": "pin", "identity_pin": True, "strength": 0.35},
{"enabled": True, "source": "image", "media_ref": "prior", "strength": 0.35}, # NOT the pin
]
scene_media_by_ref = {"pin": "pin.png", "prior": "prior.png"}
chunk, pos, neg, head, tail, phase_applied = s._apply_configured_guides(
{}, 1, guide_list, {}, [], scene_media_by_ref, [], [], object(),
identity_transfer_enabled=True, identity_transfer_phase=3.0,
)
assert calls == [("pin.png", 3.0), ("prior.png", 0.0)]
assert phase_applied is True
def test_phase_tag_off_when_identity_transfer_disabled(monkeypatch):
s = _sampler()
calls = []
monkeypatch.setattr(s, "_append_media_guide_at",
lambda chunk, filename, *a, phase_tag=0.0, **kw: (calls.append(phase_tag), chunk, [], [], 0, 0)[1:])
guide_list = [{"enabled": True, "source": "image", "media_ref": "pin", "identity_pin": True, "strength": 0.35}]
_, _, _, _, _, phase_applied = s._apply_configured_guides(
{}, 1, guide_list, {}, [], {"pin": "pin.png"}, [], [], object(),
identity_transfer_enabled=False, identity_transfer_phase=2.0,
)
assert calls == [0.0]
assert phase_applied is False
# ── _install_identity_phase / _strip_identity_phase / _rotate_identity_phase ──
class _FakeLTXV:
def _prepare_timestep(self, timestep, batch_size, hidden_dtype, **kwargs):
return ("timestep", batch_size, hidden_dtype)
def _prepare_positional_embeddings(self, pixel_coords, frame_rate, x_dtype):
# cos/sin shaped [T=10, L=4]; identity token slice under test is [6:9).
T, L = 10, 4
cos = torch.arange(T * L, dtype=torch.float32).reshape(T, L)
sin = torch.arange(T * L, dtype=torch.float32).reshape(T, L) * -1.0
return (cos, sin, False)
def _fake_model(ltxv):
inner = types.SimpleNamespace(diffusion_model=ltxv)
return types.SimpleNamespace(model=inner)
def test_install_is_idempotent_and_strip_restores_originals():
s = _sampler()
ltxv = _FakeLTXV()
orig_pt = ltxv._prepare_timestep
orig_pe = ltxv._prepare_positional_embeddings
handle1 = s._install_identity_phase(_fake_model(ltxv))
assert handle1 is not None
assert ltxv._prepare_timestep is not orig_pt
handle2 = s._install_identity_phase(_fake_model(ltxv))
assert handle2 is None # already installed — idempotent, no double-wrap
s._strip_identity_phase(handle1)
# Bound-method objects aren't identity-stable across separate attribute accesses in
# CPython (a fresh wrapper is created each time) — compare by equality (same __func__ +
# __self__) instead of `is`.
assert ltxv._prepare_timestep == orig_pt
assert ltxv._prepare_positional_embeddings == orig_pe
assert not hasattr(ltxv, "_funpack_identity_range")
def test_prepare_timestep_stashes_range_for_tagged_entry_only():
s = _sampler()
ltxv = _FakeLTXV()
handle = s._install_identity_phase(_fake_model(ltxv))
entries = [
{"pre_filter_count": 16, "surviving_count": 2}, # unrelated guide, first
{"pre_filter_count": 16, "surviving_count": 3, "funpack_identity_phase": 2.5}, # the tagged one
{"pre_filter_count": 16, "surviving_count": 4}, # unrelated guide, after
]
ltxv._prepare_timestep(torch.zeros(1), 1, torch.float32,
resolved_guide_entries=entries, num_guide_tokens=9)
assert ltxv._funpack_identity_range == (2, 3, 9) # offset past the first entry's 2 tokens
assert ltxv._funpack_identity_phase_value == 2.5
s._strip_identity_phase(handle)
def test_prepare_timestep_clears_range_without_tagged_entry():
s = _sampler()
ltxv = _FakeLTXV()
handle = s._install_identity_phase(_fake_model(ltxv))
ltxv._funpack_identity_range = (0, 1, 1) # stale from a previous scene
entries = [{"pre_filter_count": 16, "surviving_count": 5}]
ltxv._prepare_timestep(torch.zeros(1), 1, torch.float32,
resolved_guide_entries=entries, num_guide_tokens=5)
assert ltxv._funpack_identity_range is None
s._strip_identity_phase(handle)
def test_prepare_pe_rotates_only_the_tagged_range():
s = _sampler()
ltxv = _FakeLTXV()
handle = s._install_identity_phase(_fake_model(ltxv))
baseline_cos, baseline_sin, _ = ltxv._prepare_positional_embeddings(None, 25, torch.float32)
entries = [
{"pre_filter_count": 16, "surviving_count": 2},
{"pre_filter_count": 16, "surviving_count": 3, "funpack_identity_phase": 2.5},
{"pre_filter_count": 16, "surviving_count": 4},
]
ltxv._prepare_timestep(torch.zeros(1), 1, torch.float32,
resolved_guide_entries=entries, num_guide_tokens=9)
cos, sin, split_mode = ltxv._prepare_positional_embeddings(None, 25, torch.float32)
# T=10, num_guide_tokens=9 -> guide_start=1; offset=2, length=3 -> rotated slice [3:6).
untouched = list(range(0, 3)) + list(range(6, 10))
for i in untouched:
assert torch.equal(cos[i], baseline_cos[i]), f"row {i} should be untouched"
assert torch.equal(sin[i], baseline_sin[i]), f"row {i} should be untouched"
rotated_rows = [3, 4, 5]
changed = any(not torch.equal(cos[i], baseline_cos[i]) for i in rotated_rows)
assert changed, "rotated slice should differ from baseline"
assert split_mode is False # passthrough element preserved
s._strip_identity_phase(handle)
def test_rotate_identity_phase_is_noop_for_zero_phase_or_length():
s = _sampler()
pe = (torch.ones(5, 4), torch.zeros(5, 4), False)
assert s._rotate_identity_phase(pe, 0, 0, 5, 2.0) is pe
assert s._rotate_identity_phase(pe, 0, 2, 5, 0.0) is pe
def test_rotate_identity_phase_rejects_out_of_bounds_range():
s = _sampler()
cos = torch.arange(20, dtype=torch.float32).reshape(5, 4)
sin = -cos.clone()
pe = (cos, sin, False)
out = s._rotate_identity_phase(pe, offset=10, length=3, num_guide_tokens=5, phase=2.0)
assert out is pe # start/end fall outside T -> safe no-op