v4 read the prompt length from transformer_options['c_crossattn'], which ComfyUI never sets, so every block fell back to stock. It also stood down on any i2v scene (per-token mod rows). Both fixed; tested on a real tiny H3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
7.9 KiB
Python
191 lines
7.9 KiB
Python
"""The shadow pass itself: H3's block forward, run twice, with a NAG push between.
|
|
|
|
Ported from the community "H3 Shadow Negative" pack (MIT), Basic scope, via v4's
|
|
h3_shadow_negative.py. What changed from v4, and why:
|
|
|
|
* **The prompt length comes from the model call.** v4 read it from
|
|
`transformer_options["c_crossattn"]`, which no ComfyUI version sets (checked
|
|
locally and on upstream master, 2026-09-27) -- so every block fell straight
|
|
back to the stock forward and the feature never ran. Here a DIFFUSION_MODEL
|
|
wrapper records `context.shape[1]` for each call.
|
|
* **Progress through the schedule** is the exact step index (core's
|
|
`current_step`), not a guess from a `timestep` key that was never present.
|
|
* **The shadow state resets per model call** in that same wrapper, instead of
|
|
assuming block 0 is always hooked and always first.
|
|
"""
|
|
|
|
import torch
|
|
|
|
|
|
def nag_blend(pos, neg, scale, tau, alpha, channel_dim=1, eps=1e-6):
|
|
"""Push `pos` away from `neg`, norm-capped at `tau` x pos's own norm.
|
|
|
|
scale 1 is a no-op; alpha blends guided (1) against untouched (0).
|
|
"""
|
|
s, a = float(scale), float(alpha)
|
|
if s == 1.0 or a == 0.0:
|
|
return pos
|
|
guided = pos * s - neg * (s - 1.0)
|
|
pos_norm = torch.linalg.vector_norm(pos.float(), ord=2, dim=channel_dim, keepdim=True)
|
|
guided_norm = torch.linalg.vector_norm(guided.float(), ord=2, dim=channel_dim, keepdim=True)
|
|
ratio = guided_norm / (pos_norm + eps)
|
|
cap = torch.clamp(float(tau) / ratio.clamp_min(eps), max=1.0).to(guided.dtype)
|
|
return guided * cap * a + pos * (1.0 - a)
|
|
|
|
|
|
class State:
|
|
"""One run's worth: the negative text, and the shadow stream as it goes deeper."""
|
|
|
|
def __init__(self, dm, video_scale, audio_scale, tau, alpha, start, end):
|
|
self.dm = dm
|
|
self.video_scale, self.audio_scale = float(video_scale), float(audio_scale)
|
|
self.tau, self.alpha = float(tau), float(alpha)
|
|
self.start, self.end = float(start), float(end)
|
|
self.negative = None # [L, text_dim] or [L, hidden]; set per sampling run
|
|
self.text_len = None # the POSITIVE context length; set per model call
|
|
self.neg_h = None # shadow stream; reset per model call
|
|
|
|
|
|
def _tag_is(row, tag):
|
|
"""A mod row is an int, or -- once an i2v pin or mask gives rows their own
|
|
strength -- a per-token LongTensor. v4 called int() on the tensor, which
|
|
raised, so it never ran on an i2v scene."""
|
|
if torch.is_tensor(row):
|
|
return bool(((row % 3) == tag).all())
|
|
return int(row) % 3 == tag
|
|
|
|
|
|
def _mod_one(h, shift, scale, row):
|
|
return h.mul_(1.0 + scale[row].to(h.dtype)).add_(shift[row].to(h.dtype))
|
|
|
|
|
|
def _gate_slice(x, residual, gate, a, b, row):
|
|
x[a:b].addcmul_(residual[a:b], gate[row].to(x.dtype))
|
|
|
|
|
|
def _prepare_negative(state, x, to):
|
|
neg = state.negative
|
|
if not torch.is_tensor(neg):
|
|
return None
|
|
if neg.ndim == 3:
|
|
neg = neg[0]
|
|
if neg.ndim != 2 or neg.shape[0] == 0:
|
|
return None
|
|
neg = neg.to(device=x.device, dtype=x.dtype)
|
|
if neg.shape[-1] != state.dm.hidden_size:
|
|
neg = state.dm.token_refiner(state.dm.condition_proj(neg), transformer_options=to)
|
|
return neg
|
|
|
|
|
|
def _presentation_plan(mod_segments, text_len):
|
|
"""(runs inside the text span, the user-prompt run) or (None, None).
|
|
|
|
H3 tags prompt text 1 and presentation pads 0/2; the tokenizer puts the
|
|
user's prompt after any media labels, so the LAST tag-1 run is the one the
|
|
negative replaces. Everything else in the span stays the positive's.
|
|
"""
|
|
runs = []
|
|
for seg in mod_segments:
|
|
a, b, _row = seg
|
|
if a >= text_len:
|
|
break
|
|
if b > text_len:
|
|
return None, None
|
|
runs.append(seg)
|
|
text_runs = [r for r in runs if _tag_is(r[2], 1)]
|
|
if not runs or not text_runs:
|
|
return None, None
|
|
return runs, text_runs[-1]
|
|
|
|
|
|
def _negative_rope(state, rope, start, n, x):
|
|
"""RoPE for the replacement text at the prompt's own origin."""
|
|
stop = start + n
|
|
if stop <= state.text_len:
|
|
return rope[:, start:stop]
|
|
from comfy.ldm.minimax.model import rope_rotation_table
|
|
pos = torch.zeros(n, 3, dtype=torch.float64)
|
|
pos[:, 0] = torch.arange(start, stop, dtype=torch.float64)
|
|
return rope_rotation_table(state.dm.rope_freqs(pos, x.device), x.dtype)
|
|
|
|
|
|
def forward(state, block, args):
|
|
"""The block, with the shadow push. None means "run the stock block"; the
|
|
caller does that, so a layout this cannot prove costs nothing."""
|
|
x, t_emb = args["img"], args["t_emb"]
|
|
segs, rope, to = args["mod_segments"], args["rope_freqs"], args["transformer_options"]
|
|
text_len = state.text_len
|
|
if text_len is None or len(segs) < 3:
|
|
return None, "the prompt length was not seen for this call"
|
|
|
|
audio_a, audio_b, audio_row = segs[-2]
|
|
video_a, video_b, video_row = segs[-1]
|
|
if (not _tag_is(audio_row, 2) or not _tag_is(video_row, 0)
|
|
or audio_b != video_a or video_b != x.shape[0]):
|
|
return None, "could not prove where the target audio and video rows are"
|
|
runs, user_run = _presentation_plan(segs, text_len)
|
|
if runs is None:
|
|
return None, "could not find the prompt inside the text span"
|
|
|
|
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = block.adaln_proj(t_emb)
|
|
h_pos = block.norm1(x)
|
|
for a, b, row in segs:
|
|
_mod_one(h_pos[a:b], shift_msa, scale_msa, row)
|
|
attn_pos = block.attn(h_pos, rope_freqs=rope, transformer_options=to)
|
|
|
|
neg_h = state.neg_h if state.neg_h is not None else _prepare_negative(state, x, to)
|
|
if neg_h is None:
|
|
return None, "there is no negative prompt to push away from"
|
|
|
|
# The shadow sequence: the same presentation span with the user's prompt
|
|
# swapped for the negative, then the positive's own audio/video rows.
|
|
text_row = user_run[2]
|
|
neg_norm = _mod_one(block.norm1(neg_h), shift_msa, scale_msa, text_row)
|
|
neg_rope = _negative_rope(state, rope, user_run[0], int(neg_norm.shape[0]), h_pos)
|
|
neg_norm = neg_norm[:neg_rope.shape[1]]
|
|
h_parts, r_parts, cursor, neg_a = [], [], 0, 0
|
|
for a, b, row in runs:
|
|
if (a, b) == user_run[:2]:
|
|
neg_a = cursor
|
|
h_parts.append(neg_norm)
|
|
r_parts.append(neg_rope)
|
|
cursor += neg_norm.shape[0]
|
|
else:
|
|
h_parts.append(h_pos[a:b])
|
|
r_parts.append(rope[:, a:b])
|
|
cursor += b - a
|
|
neg_b, suffix = neg_a + neg_norm.shape[0], cursor
|
|
h_parts.append(h_pos[text_len:video_b])
|
|
r_parts.append(rope[:, text_len:video_b])
|
|
attn_neg = block.attn(torch.cat(h_parts, 0), rope_freqs=torch.cat(r_parts, 1),
|
|
transformer_options=to)
|
|
|
|
def shadow(a, b):
|
|
return attn_neg[suffix + a - text_len:suffix + b - text_len]
|
|
|
|
if state.video_scale != 1.0:
|
|
attn_pos[video_a:video_b] = nag_blend(attn_pos[video_a:video_b], shadow(video_a, video_b),
|
|
state.video_scale, state.tau, state.alpha)
|
|
if state.audio_scale != 1.0:
|
|
attn_pos[audio_a:audio_b] = nag_blend(attn_pos[audio_a:audio_b], shadow(audio_a, audio_b),
|
|
state.audio_scale, state.tau, state.alpha)
|
|
|
|
out = x.clone()
|
|
for a, b, row in segs:
|
|
_gate_slice(out, attn_pos, gate_msa, a, b, row)
|
|
h2 = block.norm2(out)
|
|
for a, b, row in segs:
|
|
_mod_one(h2[a:b], shift_mlp, scale_mlp, row)
|
|
mlp_pos = block.mlp(h2)
|
|
for a, b, row in segs:
|
|
_gate_slice(out, mlp_pos, gate_mlp, a, b, row)
|
|
|
|
# Advance the shadow stream through this block too, so the next block's
|
|
# push is against the negative text at the same depth.
|
|
neg_x = neg_h[:neg_b - neg_a].clone()
|
|
neg_x.addcmul_(attn_neg[neg_a:neg_b], gate_msa[text_row].to(neg_x.dtype))
|
|
neg2 = _mod_one(block.norm2(neg_x), shift_mlp, scale_mlp, text_row)
|
|
neg_x.addcmul_(block.mlp(neg2), gate_mlp[text_row].to(neg_x.dtype))
|
|
state.neg_h = neg_x
|
|
return {"img": out}, None
|