Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
190 lines
8.9 KiB
Python
190 lines
8.9 KiB
Python
"""Identity bank: a liked clip's look is kept and leaned on in the next ones (LTX).
|
|
|
|
BachVid ("raw K/V identity bank", arXiv 2510.21696): the attention keys and values a liked run used at a few
|
|
blocks are the look the model had settled on. Later runs lean toward them at the same positions, with no
|
|
reference picture and no training. KV-Lock ("When to Lock Attention", arXiv 2603.09657): the lean is
|
|
scheduled by how unsettled the picture is, harder when it drifts and softer when it has settled.
|
|
|
|
Every run's last step is kept on the taste key until you rate it. A like adds it to the bank (the average
|
|
of the liked runs); a dislike is ignored. The bank is only used on a run of the same size (same tokens), and
|
|
the run says when it is not. Overlap is a lerp of the running keys and values toward the bank's, so no
|
|
sequence length or RoPE changes.
|
|
|
|
Not the paper's concat-attend and not its DDIM inversion (a few-step distilled model inverts poorly): this
|
|
is the v4 form, which was validated on LTX 2.3. KV-Lock here reads how much the model's prediction moves
|
|
between steps, relative to its own average in the run, instead of v4's absolute threshold.
|
|
|
|
Memory: one picture-token row of keys and values per kept block per remembered run, in half precision
|
|
(about 100 MB per block for a 768x512, 121-frame clip), so the defaults keep two blocks and three runs.
|
|
"""
|
|
|
|
import torch
|
|
from comfy.patcher_extension import WrappersMP
|
|
|
|
from ..._core import dit_hooks, log, registry, streams
|
|
|
|
ID = "identity_bank"
|
|
TITLE = "Identity bank"
|
|
MOUNT = "generation.sampling"
|
|
STAGE = "sampling"
|
|
CATEGORY = "guidance"
|
|
STATUS = "experimental"
|
|
REQUIRES = ["block_scope", "ltx_av"]
|
|
USES = ["taste_store"]
|
|
|
|
SETTINGS = {
|
|
"enabled": {
|
|
"type": "bool", "default": False,
|
|
"label": "Identity bank",
|
|
"hint": "Keeps the character and place of clips you like in the next ones, without a reference picture. Needs a Taste key and one liked clip of the same size.",
|
|
},
|
|
"strength": {
|
|
"type": "float", "default": 0.1, "min": 0.0, "max": 0.6, "step": 0.02,
|
|
"label": "How hard", "ui": "slider",
|
|
"hint": "0 = only keep. Higher locks harder and costs variety; past 0.3 the picture starts to repeat the liked one.",
|
|
"when": {"enabled": True},
|
|
},
|
|
"blocks": {
|
|
"type": "text", "model_blocks": True, "default": "20,30",
|
|
"label": "Blocks",
|
|
"hint": "Where the look is kept and applied, e.g. 14,20,30. Each block kept costs about 100 MB per remembered clip.",
|
|
"when": {"enabled": True},
|
|
},
|
|
"lock": {
|
|
"type": "bool", "default": True,
|
|
"label": "Lock harder when it drifts",
|
|
"hint": "KV-Lock: leans harder on steps where the picture is still changing a lot and softer once it has settled.",
|
|
"when": {"enabled": True},
|
|
},
|
|
"remember": {
|
|
"type": "int", "default": 3, "min": 1, "max": 8,
|
|
"label": "Clips remembered",
|
|
"hint": "How many recent clips wait for a rating on this key.",
|
|
"when": {"enabled": True},
|
|
},
|
|
}
|
|
|
|
KIND = "identity_bank"
|
|
GATE = (0.35, 0.85) # sigmas the lean is on for: the early steps set the layout, the last ones the fine detail
|
|
LIMIT = 0.6 # never lean more than this: the picture would be the bank's
|
|
WINDOW = 5 # steps the lock averages over
|
|
SCOPE = "funui_identity_bank"
|
|
|
|
|
|
def _say(message):
|
|
log.once(f"{ID}:{message}", log.ALERT, "FunUI Identity bank", message)
|
|
|
|
|
|
def averaged(rows):
|
|
"""{name: mean tensor} of the liked rows, per name over the rows that agree in shape, or {}."""
|
|
liked = [r["rows"] for r in rows if r["reward"] > 0 and r["rows"]]
|
|
bank = {}
|
|
for name in {n for r in liked for n in r}:
|
|
same = [r[name] for r in liked if name in r and r[name].shape == next(x[name] for x in liked if name in x).shape]
|
|
bank[name] = torch.stack([t.float() for t in same]).mean(0).half()
|
|
return bank
|
|
|
|
|
|
def lock_scale(history, change):
|
|
"""The lean's multiplier for the next step: this step's change over the run's own average, held in 0.5..2.
|
|
1 until there is something to compare with."""
|
|
history.append(change)
|
|
del history[:-WINDOW]
|
|
if len(history) < 2:
|
|
return 1.0
|
|
mean = sum(history[:-1]) / (len(history) - 1)
|
|
return max(0.5, min(2.0, change / mean)) if mean > 1e-9 else 1.0
|
|
|
|
|
|
def install(patcher, values, key):
|
|
if not values.get("enabled"):
|
|
return None
|
|
taste = registry.current().ask("taste_store", patcher)
|
|
if taste is None:
|
|
_say("off: set a Taste key first -- it is what your ratings teach")
|
|
return None
|
|
dm = getattr(patcher.model, "diffusion_model", None)
|
|
width = getattr(dm, "inner_dim", None)
|
|
if width is None:
|
|
_say("off: could not read the picture width of this model")
|
|
return None
|
|
blocks, problems = dit_hooks.parse_blocks(values.get("blocks"), dit_hooks.block_count(patcher))
|
|
for problem in problems:
|
|
_say(f"ignored {problem}")
|
|
if not blocks:
|
|
raise ValueError(f"no usable blocks in {values.get('blocks')!r}, so nothing would be kept")
|
|
strength = min(max(float(values.get("strength", 0.1)), 0.0), LIMIT)
|
|
locking = bool(values.get("lock", True))
|
|
live = {"bank": {}, "scale": 1.0, "history": [], "last": None, "used": 0, "skipped": 0, "cast": {}}
|
|
|
|
def fresh():
|
|
live.update(bank=averaged(taste.rows(KIND)), scale=1.0, history=[], last=None, used=0, skipped=0, cast={})
|
|
liked = sum(1 for r in taste.rows(KIND) if r["reward"] > 0)
|
|
log.once(f"{ID}:state", log.INFO, "FunUI Identity bank",
|
|
f"key {taste.key!r}: {liked} liked clip(s) in the bank, " + (f"leaning at {strength:g}" if live["bank"] and strength > 0 else "only keeping"))
|
|
|
|
captured = taste.collect(patcher, key, KIND, keep=int(values.get("remember", 3)), only="liked", fresh=fresh)
|
|
|
|
def scoped(block):
|
|
def hook(args, extra):
|
|
to = dict(args.get("transformer_options") or {})
|
|
to[SCOPE] = block
|
|
return extra["original_block"]({**args, "transformer_options": to})
|
|
return hook
|
|
|
|
for b in blocks:
|
|
dit_hooks.add_block_hook(patcher, key, b, scoped(b))
|
|
|
|
def override(func, q, k, v, *args, **kwargs):
|
|
to = kwargs.get("transformer_options") or {}
|
|
block = to.get(SCOPE)
|
|
if block is None or dit_hooks.probing(to) or q.ndim != 3 or q.shape != k.shape or q.shape[-1] != width:
|
|
return func(q, k, v, *args, **kwargs) # not the picture's own attention in a kept block
|
|
if dit_hooks.last_step(to):
|
|
captured[f"k{block}"], captured[f"v{block}"] = k.mean(0, dtype=torch.float32).half().detach(), v.mean(0, dtype=torch.float32).half().detach()
|
|
bank_k, bank_v = live["bank"].get(f"k{block}"), live["bank"].get(f"v{block}")
|
|
sigma = to.get("sigmas")
|
|
if bank_k is None or strength <= 0.0 or sigma is None or not GATE[0] <= float(sigma.reshape(-1)[0]) <= GATE[1]:
|
|
return func(q, k, v, *args, **kwargs)
|
|
if bank_k.shape != k.shape[1:]:
|
|
live["skipped"] += 1
|
|
return func(q, k, v, *args, **kwargs)
|
|
cast = live["cast"].setdefault((block, k.device, k.dtype), (bank_k.to(k.device, k.dtype), bank_v.to(k.device, k.dtype)))
|
|
alpha = min(strength * live["scale"], LIMIT)
|
|
live["used"] += 1
|
|
return func(q, k + alpha * (cast[0] - k), v + alpha * (cast[1] - v), *args, **kwargs)
|
|
|
|
dit_hooks.add_attention_override(patcher, key, override)
|
|
|
|
def apply_model(executor, x, t, *args, **kwargs):
|
|
out = executor(x, t, *args, **kwargs)
|
|
named = streams.model_args(args, kwargs)
|
|
if not locking or dit_hooks.probing(named.get("transformer_options") or {}):
|
|
return out # a probe pass (step merging's second answer) is not a step of this run
|
|
split = streams.video_of(out, named)
|
|
if split is None:
|
|
return out
|
|
video = split[0].detach().float()
|
|
if live["last"] is not None and live["last"].shape == video.shape:
|
|
change = float(torch.linalg.vector_norm(video - live["last"]) / torch.linalg.vector_norm(video).clamp_min(1e-9))
|
|
live["scale"] = lock_scale(live["history"], change)
|
|
live["last"] = video
|
|
return out
|
|
|
|
patcher.add_wrapper_with_key(WrappersMP.APPLY_MODEL, key, apply_model)
|
|
|
|
def finish(executor, *args, **kwargs):
|
|
out = executor(*args, **kwargs)
|
|
if live["bank"] and strength > 0:
|
|
if live["skipped"] and not live["used"]:
|
|
_say("the bank is from a clip of another size, so it was not used this run")
|
|
elif not live["used"]:
|
|
_say("no attention call reached the bank's blocks this run (another feature may have replaced attention)")
|
|
return out
|
|
|
|
patcher.add_wrapper_with_key(WrappersMP.OUTER_SAMPLE, key + ".report", finish)
|
|
return f"keeps blocks {','.join(map(str, blocks))}; leans {strength:g}" + (" (locks harder when it drifts)" if locking else "") + ", read fresh every run"
|
|
|
|
|
|
PROVIDES = {"modifier": install}
|