feat(v5): randomizer and block repeat modules for H3, on shared chainable block hooks

Block repeat's video-only mode now really keeps audio single-pass: H3 blocks
write in place, so v4 restored audio from an already-repeated stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-27 17:29:42 +03:00
co-authored by Claude Opus 5.5
parent ec6e9893b4
commit c3ad0dfd79
11 changed files with 858 additions and 8 deletions
+64
View File
@@ -42,3 +42,67 @@ def comfyui():
if COMFYUI is None:
pytest.skip("no ComfyUI source on this machine")
return COMFYUI
class TinyH3:
"""A REAL MiniMax H3 -- ComfyUI's own class, four blocks wide as a thumb.
Random weights, CPU, milliseconds per forward. Every H3 modifier is tested
against this rather than a mock, because what breaks these features is the
real forward's shapes (joint sequence without a batch axis, tensor
containers around q/k/v, mod_segments) and a mock only has the shapes its
author remembered.
"""
def __init__(self, blocks=4, seed=0):
import torch
import comfy.ops
from comfy.latent_formats import MiniMaxH3AV
from comfy.ldm.minimax.model import MiniMaxH3Model
from comfy.model_patcher import ModelPatcher
torch.manual_seed(seed)
dm = MiniMaxH3Model(
hidden_size=64, num_layers=blocks, token_refiner_num_layers=1,
num_attention_heads=2, attention_head_dim=32, ffn_hidden_size=64,
text_dim=48, timestep_input_dim=32, time_embed_hidden_size=64,
time_embed_dim=32, rope_inv_freq_len=4, dtype=torch.float32,
operations=comfy.ops.manual_cast)
for p in dm.parameters():
torch.nn.init.normal_(p, std=0.05)
p.requires_grad_(False) # the in-place rope kernel refuses autograd
dm.rope.inv_freq.copy_(torch.rand(4))
class Config:
latent_format = MiniMaxH3AV
class Base(torch.nn.Module):
pass
base = Base()
base.diffusion_model = dm
base.model_config = Config()
self.patcher = ModelPatcher(base, load_device=torch.device("cpu"),
offload_device=torch.device("cpu"))
self.video = torch.randn(1, 24, 2, 4, 4)
self.audio = torch.randn(1, 32, 2, 3)
self.context = torch.randn(1, 5, 48)
def run(self, patcher=None, sigma=0.5, sigmas=None, negative=None):
"""One denoise call through `patcher`'s transformer_options, as the
sampler would make it. Returns (video, audio)."""
import torch
patcher = patcher or self.patcher
to = dict(patcher.model_options.get("transformer_options", {}))
sched = sigmas if sigmas is not None else torch.tensor([1.0, sigma, 0.0])
to.setdefault("sample_sigmas", sched)
to["sigmas"] = torch.tensor([sigma])
dm = patcher.model.diffusion_model
with torch.inference_mode():
return dm([self.video, self.audio], torch.tensor([sigma * 1000.0]),
self.context, transformer_options=to)
@pytest.fixture
def tiny_h3(comfyui):
return TinyH3()
+182
View File
@@ -0,0 +1,182 @@
"""Block hooks and attention overrides that stack, come off again, and cannot end a run.
ComfyUI gives a transformer two extension points that are NOT lists:
* `transformer_options["patches_replace"]["dit"][("double_block", i)]` -- one
callable per block, which replaces that block's call.
* `transformer_options["optimized_attention_override"]` -- one callable for every
attention call in the model.
One slot each. A second modifier that simply assigns the slot silently deletes the
first one's effect, and v4 learned that the slow way (temperature vanishing under
REINS, with no error). So everything here CHAINS: a hook is written as if it were
alone -- it calls `extra["original_block"](args)`, or `func(q, k, v, ...)` -- and
what it calls is really the rest of the chain. No hook ever needs to know another
exists.
Model-agnostic on purpose. The two slot names are ComfyUI conventions shared by
several DiTs; which models actually honour them is a trait the MODEL module
announces (`dit_block_hooks`), never something this file decides.
Removal: every installed callable carries `patching.TAG` and a pointer to what it
wrapped, so `patching.strip` unwinds ours and puts back whatever was underneath --
including someone else's hook we chained through.
"""
from . import patching
PREV = "_funpack_prev"
BLOCK = "double_block"
def _transformer_options(patcher) -> dict:
options = patcher.model_options
to = options.get("transformer_options")
if not isinstance(to, dict):
to = {}
options["transformer_options"] = to
return to
def _guard(patcher, fn, neutral):
"""Guarded when the patcher can guard (it is always a GuardedPatcher inside a
run); a bare patcher -- tests, a direct call -- gets the hook unchanged."""
wrap = getattr(patcher, "guarded", None)
return wrap(fn, neutral) if callable(wrap) else fn
def _mark(fn, key, prev):
patching.tag(fn, key)
setattr(fn, PREV, prev)
return fn
def add_block_hook(patcher, key: str, index: int, hook) -> None:
"""Put `hook(args, extra)` around block `index`, on top of whatever is there.
`extra["original_block"]` inside the hook runs the rest of the chain, ending in
the real block. A failing hook is dropped for the rest of the run and the block
runs as though it had never been installed.
"""
to = _transformer_options(patcher)
replace = to.setdefault("patches_replace", {})
dit = replace.setdefault("dit", {})
slot = (BLOCK, int(index))
inner = dit.get(slot)
def below(extra):
if inner is None:
return extra
original = extra["original_block"]
return {**extra, "original_block": lambda a: inner(a, {**extra, "original_block": original})}
def neutral(args, extra):
return below(extra)["original_block"](args)
guarded = _guard(patcher, lambda args, extra: hook(args, below(extra)), neutral)
def chained(args, extra):
return guarded(args, extra)
dit[slot] = _mark(chained, key, inner)
def add_attention_override(patcher, key: str, override) -> None:
"""Put `override(func, q, k, v, heads, **kw)` around every attention call.
Calling `func(...)` inside it runs the rest of the chain, ending in the real
backend. The override sees plain tensors: ComfyUI unwraps its tensor
containers before an override is called.
"""
to = _transformer_options(patcher)
inner = to.get("optimized_attention_override")
def rest(func):
if inner is None:
return func
return lambda *a, **kw: inner(func, *a, **kw)
def neutral(func, *args, **kwargs):
return rest(func)(*args, **kwargs)
guarded = _guard(patcher, lambda func, *a, **kw: override(rest(func), *a, **kw), neutral)
def chained(func, *args, **kwargs):
return guarded(func, *args, **kwargs)
to["optimized_attention_override"] = _mark(chained, key, inner)
def block_count(model) -> int:
"""How many hookable blocks the model has, or 0 when it cannot be read."""
root = getattr(getattr(model, "model", None), "diffusion_model", None)
blocks = getattr(root, "blocks", None)
try:
return len(blocks)
except TypeError:
return 0
def parse_blocks(spec, n_blocks: int):
""""40" | "38-42" | "10,40,44" | "" -> (sorted indices, [problems]).
Out-of-range and unparseable entries are reported, not silently dropped: a
typo that turns a setting into a no-op is exactly the inert knob this project
refuses to ship, so the caller says what was ignored.
"""
out, problems = set(), []
for part in str(spec or "").replace(" ", "").split(","):
if not part:
continue
try:
if "-" in part[1:]:
a, b = part.split("-", 1)
lo, hi = sorted((int(a), int(b)))
else:
lo = hi = int(part)
except ValueError:
problems.append(f"{part!r} is not a block number or range")
continue
wanted = set(range(lo, hi + 1))
kept = {i for i in wanted if 0 <= i < n_blocks}
if kept != wanted:
problems.append(f"{part!r} reaches past this model's blocks 0-{n_blocks - 1}")
out |= kept
return sorted(out), problems
def current_step(transformer_options):
"""(step index, step count) of the denoise call in progress, or None.
Core puts the whole schedule in `sample_sigmas` and this call's sigma in
`sigmas`; the index is exact, so a step window never has to guess a sigma
threshold per schedule.
"""
import torch
to = transformer_options or {}
sched, cur = to.get("sample_sigmas"), to.get("sigmas")
if sched is None or cur is None:
return None
cur = cur.reshape(-1)[0].to(sched.dtype)
hit = torch.nonzero(torch.isclose(sched, cur, rtol=1e-4))
if not len(hit):
return None
return int(hit[0]), int(sched.shape[0]) - 1
def target_rows(mod_segments, seq_len, device, stream):
"""[seq_len] bool mask of the model's TARGET `stream` ("video" | "audio")
rows, or None when no model module can prove it for this call.
Asked of whichever model module announces `target_rows`, so core never
learns a model's sequence layout; the first provider with an answer wins.
"""
from . import registry
for _spec, provider in registry.current().providers("target_rows"):
try:
mask = provider(mod_segments, seq_len, device, stream)
except Exception: # noqa: BLE001
continue
if mask is not None:
return mask
return None
+19 -2
View File
@@ -74,8 +74,16 @@ def _strip_options(options: dict, prefix: str) -> int:
removed += len(value) - len(keep)
options[name] = keep
elif callable(value) and _ours(value, prefix):
options.pop(name, None)
removed += 1
# A chained hook remembers what it wrapped (dit_hooks.PREV): put that
# back rather than dropping it, or stripping ours deletes someone
# else's hook along with it.
while callable(value) and _ours(value, prefix):
value = getattr(value, "_funpack_prev", None)
removed += 1
if value is None:
options.pop(name, None)
else:
options[name] = value
elif isinstance(value, dict):
removed += _strip_options(value, prefix)
return removed
@@ -221,5 +229,14 @@ class GuardedPatcher:
return install
def guarded(self, fn, neutral):
"""`fn` guarded like any hook installed through this patcher, for hooks
that go in through a slot rather than a `set_model_*` method (block
replacements, the attention override). `neutral` has the hook's own
signature and returns what the call would have without it."""
if not self._guarding:
return fn
return guard(fn, self._key, neutral, self._dropped)
def __setattr__(self, name, value):
setattr(self._patcher, name, value)
+102
View File
@@ -0,0 +1,102 @@
"""Chained block hooks / attention overrides, on a REAL H3 forward."""
import pytest
import torch
@pytest.fixture(autouse=True)
def _needs_comfy(comfyui):
"""Imports comfy."""
def _clone(tiny):
return tiny.patcher.clone()
def test_two_block_hooks_on_one_block_both_run_and_the_output_is_the_block(tiny_h3):
from core import dit_hooks
base = tiny_h3.run()
p = _clone(tiny_h3)
seen = []
def hook(name):
def h(args, extra):
seen.append(name)
return extra["original_block"](args)
return h
dit_hooks.add_block_hook(p, "funpack.a", 1, hook("a"))
dit_hooks.add_block_hook(p, "funpack.b", 1, hook("b"))
out = tiny_h3.run(p)
assert seen == ["b", "a"], "the second hook replaced the first instead of chaining"
assert torch.allclose(out[0], base[0]) and torch.allclose(out[1], base[1])
def test_two_attention_overrides_both_see_every_call(tiny_h3):
from core import dit_hooks
p = _clone(tiny_h3)
calls = {"a": 0, "b": 0}
def ov(name):
def o(func, q, k, v, *a, **kw):
calls[name] += 1
return func(q, k, v, *a, **kw)
return o
dit_hooks.add_attention_override(p, "funpack.a", ov("a"))
dit_hooks.add_attention_override(p, "funpack.b", ov("b"))
tiny_h3.run(p)
# 4 DiT blocks + 1 token-refiner block
assert calls == {"a": 5, "b": 5}
def test_strip_removes_ours_and_puts_back_a_foreign_hook_underneath(tiny_h3):
from core import dit_hooks, patching
p = _clone(tiny_h3)
foreign = []
def theirs(args, extra):
foreign.append(1)
return extra["original_block"](args)
dit = p.model_options["transformer_options"].setdefault("patches_replace", {}).setdefault("dit", {})
dit[("double_block", 0)] = theirs
dit_hooks.add_block_hook(p, "funpack.x", 0, lambda a, e: e["original_block"](a))
dit_hooks.add_attention_override(p, "funpack.x", lambda f, *a, **k: f(*a, **k))
assert patching.strip(p, "funpack.") == 2
to = p.model_options["transformer_options"]
assert to["patches_replace"]["dit"][("double_block", 0)] is theirs
assert "optimized_attention_override" not in to
tiny_h3.run(p)
assert foreign == [1]
def test_a_failing_block_hook_is_dropped_and_the_run_matches_an_unhooked_one(tiny_h3):
from core import dit_hooks, patching
base = tiny_h3.run()
p = _clone(tiny_h3)
dropped = patching.Dropped()
guarded = patching.GuardedPatcher(p, "funpack.bad", dropped)
def bad(args, extra):
raise RuntimeError("boom")
def bad_attn(func, *a, **k):
raise RuntimeError("boom")
dit_hooks.add_block_hook(guarded, "funpack.bad", 2, bad)
dit_hooks.add_attention_override(guarded, "funpack.bad", bad_attn)
out = tiny_h3.run(p)
assert "funpack.bad" in dropped
assert torch.allclose(out[0], base[0])
@pytest.mark.parametrize("spec,expect,problems", [
("1", [1], 0), ("0-2", [0, 1, 2], 0), ("3,1", [1, 3], 0),
("2-9", [2, 3], 1), ("x", [], 1), ("", [], 0), ("-1", [], 1),
])
def test_parse_blocks_reports_what_it_ignores(spec, expect, problems):
from core import dit_hooks
got, said = dit_hooks.parse_blocks(spec, 4)
assert got == expect and len(said) == problems
+3 -3
View File
@@ -11,8 +11,8 @@ leading underscore keeps it out of the module scan.
"""
try: # root on sys.path: tests, dev server
from core import chain, contract, log, media, patching, registry, relations, run, schema, traits
from core import chain, contract, dit_hooks, log, media, patching, registry, relations, run, schema, traits
except ImportError: # inside ComfyUI: a subpackage of the pack
from ..core import chain, contract, log, media, patching, registry, relations, run, schema, traits
from ..core import chain, contract, dit_hooks, log, media, patching, registry, relations, run, schema, traits
__all__ = ["chain", "contract", "log", "media", "patching", "registry", "relations", "run", "schema", "traits"]
__all__ = ["chain", "contract", "dit_hooks", "log", "media", "patching", "registry", "relations", "run", "schema", "traits"]
+8 -3
View File
@@ -28,6 +28,7 @@ from comfy_extras.nodes_minimax_h3 import temporal_shape
from ..._core import traits as _traits
from .pipeline import presets as _pipeline_presets
from .rows import target_rows as _target_rows
has_block = _traits.has_block
@@ -105,7 +106,7 @@ def probe_traits(keys) -> list:
return []
from comfy.latent_formats import MiniMaxH3AV
found = ["audio_stream"]
found = ["audio_stream", "dit_block_hooks"]
rank = _traits.LATENT_RANK.get(MiniMaxH3AV.latent_dimensions)
if rank:
found.append(rank)
@@ -118,7 +119,10 @@ def traits(model):
"""What core cannot read off the model for itself."""
if not is_h3(model):
return ()
found = ["audio_stream"]
# dit_block_hooks: its forward honours patches_replace["dit"][("double_block",
# i)] and routes every attention call through optimized_attention, which is
# what core/dit_hooks.py hooks.
found = ["audio_stream", "dit_block_hooks"]
if has_block(model, ADALN_CLASS):
found.append("adaln_modalities")
return found
@@ -178,4 +182,5 @@ def empty_latent(model, width, height, length, batch_size=1):
TRAITS = traits
PROVIDES = {"empty_latent": empty_latent, "decode": decode, "detect": detect,
"pipeline_presets": _pipeline_presets, "probe_traits": probe_traits}
"pipeline_presets": _pipeline_presets, "probe_traits": probe_traits,
"target_rows": _target_rows}
+35
View File
@@ -0,0 +1,35 @@
"""Which rows of H3's joint sequence are the TARGET video and audio.
Upstream packs `[text | cond/ref | audio | video]` and emits one mod_segments
entry per stream, so target video is the LAST entry and target audio the one
before it. Matching by modality tag alone is wrong: tag 0 also marks the i2v
anchor pin, guide keyframes, reference media and the vision tokens inside the
text span, none of which a "video rows" feature may touch (fa04d6c audit).
None -- never an all-False mask -- when the layout cannot be proven (the token
refiner's text-only calls, an upstream layout change), so callers no-op
instead of guessing.
"""
import torch
_TAG = {"video": (0, -1), "audio": (2, -2)}
def target_rows(mod_segments, seq_len, device, stream):
if stream not in _TAG:
return None
tag, at = _TAG[stream]
segs = list(mod_segments or ())
if len(segs) < 2:
return None
a, b, row = segs[at]
try:
ok = bool(((row % 3) == tag).all()) if torch.is_tensor(row) else int(row) % 3 == tag
except Exception: # noqa: BLE001
return None
if not ok or not (0 <= a < b <= seq_len):
return None
mask = torch.zeros(seq_len, dtype=torch.bool, device=device)
mask[a:b] = True
return mask
@@ -0,0 +1,95 @@
"""Randomizer: loosen or tighten how firmly chosen blocks weigh the prompt.
Scales the query before attention at the named blocks, which is softmax
temperature without ever building the score matrix: Q/T . K == (Q . K)/T. Above
1 the model is less sure which tokens matter and wanders; below 1 it commits
harder to what the prompt already made confident. Either way it can only
re-weigh what is in context, so unlike added noise it stays inside the prompt.
Validated on H3 by eye (2026-09-20), with two conditions the user found: keep it
moderate, and keep it off blocks a steering module is already pushing on.
Known-good: 0.75x (sharper) at blocks 2-10, or blocks 29-46.
Not rating-driven, on purpose: a plain dial. Coupling one hard-to-read mechanism
to another's data would make neither debuggable.
"""
from ..._core import dit_hooks, log
ID = "attention_temperature"
TITLE = "Randomizer"
MOUNT = "generation.sampling"
STAGE = "sampling"
CATEGORY = "sampling"
STATUS = "proven"
REQUIRES = ["dit_block_hooks"]
SETTINGS = {
"enabled": {
"type": "bool", "default": False,
"label": "Randomizer",
"hint": "Makes the model more adventurous or more literal with your prompt.",
},
"amount": {
"type": "float", "default": -0.25, "min": -0.9, "max": 3.0, "step": 0.05,
"label": "Amount", "ui": "slider",
"hint": "Below zero sticks closer to the prompt, above zero wanders. Keep it small.",
"when": {"enabled": True},
},
"blocks": {
"type": "text", "default": "2-10",
"label": "Blocks",
"hint": "e.g. 2-10 or 29-46. Keep away from blocks a steering feature uses.",
"when": {"enabled": True},
},
}
# Carried on the per-block copy of transformer_options, so an attention call knows
# it belongs to a named block without any shared flag that could leak.
_SCOPE = "funpack_attention_temperature"
def install(patcher, values, key):
if not values.get("enabled"):
return None
amount = float(values.get("amount", 0.0))
if amount == 0.0:
return None
blocks, problems = dit_hooks.parse_blocks(values.get("blocks"), dit_hooks.block_count(patcher))
for problem in problems:
log.once(f"{ID}:{problem}", log.ALERT, "FunPack Randomizer", f"ignored {problem}")
if not blocks:
raise ValueError(f"no usable blocks in {values.get('blocks')!r}, so nothing would change")
# The widget floor keeps this above zero; the clamp is for API callers.
temperature = max(1.0 + amount, 0.05)
def scoped(args, extra):
to = dict(args.get("transformer_options") or {})
to[_SCOPE] = temperature
return extra["original_block"]({**args, "transformer_options": to})
for index in blocks:
dit_hooks.add_block_hook(patcher, key, index, scoped)
def override(func, q, k, v, *args, **kwargs):
t = (kwargs.get("transformer_options") or {}).get(_SCOPE)
return func(q if t is None else q / t, k, v, *args, **kwargs)
dit_hooks.add_attention_override(patcher, key, override)
direction = "sharper" if amount < 0 else "looser"
return f"{temperature:g}x ({direction}) at blocks {_ranges(blocks)}"
def _ranges(blocks):
out, start = [], None
for i, b in enumerate(blocks):
if start is None:
start = b
if i + 1 == len(blocks) or blocks[i + 1] != b + 1:
out.append(str(start) if start == b else f"{start}-{b}")
start = None
return ",".join(out)
PROVIDES = {"modifier": install}
@@ -0,0 +1,75 @@
"""The randomizer, through the real modifier loader onto a real H3 forward."""
import pytest
import torch
@pytest.fixture(autouse=True)
def _needs_comfy(comfyui):
"""Imports comfy."""
def _load(tiny, **values):
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
settings = {"attention_temperature": {"enabled": True, **values}}
return FunPackLoadModifiers.execute(tiny.patcher, settings).result
def test_it_changes_the_output(tiny_h3):
base = tiny_h3.run()
patched, status = _load(tiny_h3, amount=-0.5, blocks="1-2")
assert "attention_temperature: 0.5x (sharper) at blocks 1-2" in status
out = tiny_h3.run(patched)
assert not torch.allclose(out[0], base[0]), "temperature ran and changed nothing"
def test_blocks_outside_the_list_see_the_original_query(tiny_h3):
from core import dit_hooks
patched, _ = _load(tiny_h3, amount=1.0, blocks="3")
seen = []
def spy(func, q, k, v, *a, **kw):
seen.append(float(q.abs().mean()))
return func(q, k, v, *a, **kw)
# Installed on top, so it sees q BEFORE the temperature override divides it,
# and a second spy underneath sees it after.
under = []
def spy_under(func, q, k, v, *a, **kw):
under.append(float(q.abs().mean()))
return func(q, k, v, *a, **kw)
probe = patched.clone()
dit_hooks.add_attention_override(probe, "test.spy", spy)
tiny_h3.run(probe)
# Rebuild with the spy UNDER the temperature: clone the base, spy first.
low = tiny_h3.patcher.clone()
dit_hooks.add_attention_override(low, "test.spy", spy_under)
from modules.sampling import attention_temperature as m
m.install(low, {"enabled": True, "amount": 1.0, "blocks": "3"}, key="funpack.attention_temperature")
tiny_h3.run(low)
# call order: refiner, blocks 0..3 -> only the last is halved
ratios = [u / s for u, s in zip(under, seen)]
assert [round(r, 4) for r in ratios] == [1.0, 1.0, 1.0, 1.0, 0.5]
def test_off_or_zero_installs_nothing(tiny_h3):
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
_p, status = FunPackLoadModifiers.execute(
tiny_h3.patcher, {"attention_temperature": {"enabled": True, "amount": 0.0}}).result
assert "attention_temperature" not in status.splitlines()[0]
def test_a_block_list_with_nothing_usable_is_refused_not_silently_inert(tiny_h3):
_p, status = _load(tiny_h3, amount=0.5, blocks="40-49")
assert "attention_temperature: failed to install" in status
def test_loading_twice_does_not_double_the_effect(tiny_h3):
once, _ = _load(tiny_h3, amount=-0.5, blocks="1")
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
twice, status = FunPackLoadModifiers.execute(
once, {"attention_temperature": {"enabled": True, "amount": -0.5, "blocks": "1"}}).result
assert torch.allclose(tiny_h3.run(once)[0], tiny_h3.run(twice)[0])
+181
View File
@@ -0,0 +1,181 @@
"""Block repeat: run chosen blocks a second time, inside one sampler step.
`x' = x + f(x)`, then `x'' = x' + f(x')`. No VAE round trip, no re-noise, no
extra step: one more block forward per repeat, ~2% of a step per block on a
50-block model.
What it does, from the v4 sweep (research_block_repeat_sweep): a repeat is a
TRAJECTORY RE-ROLL -- a different take on the same prompt -- not a sharpening
pass. The band 20-41 behaved best. The blocks after a repeated one were trained
on its normal output, so pushing further (more passes, wider spans) drifts out
of distribution.
Two shapes:
* **each** -- 31,31,32,32..40,40. Every block gets its own doubled output.
* **span** -- 31..40,31..40. The whole span loops, so every block still gets
input from its normal predecessor and there is ONE seam (40 -> 31) instead of
one per block. Span mode runs the span's blocks directly, so a hook another
module put on a block INSIDE the span does not run on the looped passes.
"Video only" keeps the extra pass for video rows and gives text and audio the
single pass, because repeating the audio rows is the lead suspect for the
unprompted dialogue band 31-40 invents. Attention has already mixed the doubled
audio into the video before the restore; it protects what you hear, not what
the video attended to.
"""
import torch
from ..._core import dit_hooks, log
ID = "block_repeat"
TITLE = "Block repeat"
MOUNT = "generation.sampling"
STAGE = "sampling"
CATEGORY = "sampling"
STATUS = "experimental"
REQUIRES = ["dit_block_hooks"]
SETTINGS = {
"enabled": {
"type": "bool", "default": False,
"label": "Block repeat",
"hint": "Re-rolls part of the model's thinking for a different take on the same prompt.",
},
"blocks": {
"type": "text", "default": "31-40",
"label": "Blocks",
"hint": "e.g. 31-40, or 20,35. Somewhere in 20-41 works best.",
"when": {"enabled": True},
},
"shape": {
"type": "enum", "default": "each", "options": [
{"value": "each", "label": "Each block"},
{"value": "span", "label": "Whole span"},
],
"label": "Repeat", "ui": "segmented",
"hint": "each: every block twice in place. span: the whole range loops once.",
"when": {"enabled": True},
},
"times": {
"type": "int", "default": 1, "min": 1, "max": 4,
"label": "Extra passes",
"hint": "1 = each block runs twice. More drifts further.",
"when": {"enabled": True},
},
"video_only": {
"type": "bool", "default": False,
"label": "Video only",
"hint": "Keeps the sound out of it. Try this if people start talking unprompted.",
"when": {"enabled": True},
},
"steps": {
"type": "int", "default": 0, "min": -50, "max": 50,
"label": "Which steps",
"hint": "0 = every step. 3 = only the last 3. -3 = only the first 3.",
"when": {"enabled": True},
},
}
def _say(message):
log.once(f"{ID}:{message}", log.ALERT, "FunPack Block repeat", message)
def _in_window(args, steps):
if steps == 0:
return True
where = dit_hooks.current_step(args.get("transformer_options"))
if where is None:
# Fails OPEN -- repeat every step -- and says so, rather than quietly
# never repeating at all.
_say("could not tell which step this is, so it repeats on every step this run")
return True
index, total = where
return index >= total - steps if steps > 0 else index < -steps
def _video_only(args, doubled, single):
"""Doubled video rows, single-pass everything else."""
if single.shape != doubled.shape:
return doubled
mask = dit_hooks.target_rows(args.get("mod_segments"), int(doubled.shape[0]),
doubled.device, "video")
if mask is None:
_say("could not find the video rows, so 'video only' repeated everything this run")
return doubled
return torch.where(mask.view(-1, *([1] * (doubled.dim() - 1))), doubled, single)
def install(patcher, values, key):
if not values.get("enabled"):
return None
n = dit_hooks.block_count(patcher)
blocks, problems = dit_hooks.parse_blocks(values.get("blocks"), n)
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 repeat")
times = int(values.get("times", 1))
steps = int(values.get("steps", 0))
video_only = bool(values.get("video_only"))
span = values.get("shape") == "span"
if span:
lo, hi = blocks[0], blocks[-1]
if blocks != list(range(lo, hi + 1)):
raise ValueError(f"span needs a range like 31-40, got {values.get('blocks')!r}")
real = patcher.model.diffusion_model.blocks
def run_span(args, h):
for i in range(lo, hi + 1):
h = real[i](h, args["t_emb"], args["mod_segments"], args["rope_freqs"],
transformer_options=args.get("transformer_options"))
return h
def head(args, extra):
once = run_span(args, args["img"])
if not _in_window(args, steps):
return {"img": once}
# H3's blocks add their residual INTO the input tensor, so without a
# copy the next pass overwrites `once` and "video only" restores
# audio from an already-repeated stream (v4 shipped that bug).
h = once.clone() if video_only else once
for _ in range(times):
h = run_span(args, h)
return {"img": _video_only(args, h, once) if video_only else h}
def skip(args, extra):
return {"img": args["img"]} # the head already ran this block
dit_hooks.add_block_hook(patcher, key, lo, head)
for i in range(lo + 1, hi + 1):
dit_hooks.add_block_hook(patcher, key, i, skip)
what = f"blocks {lo}-{hi} loop {times + 1}x as one span"
else:
def repeat(args, extra):
once = extra["original_block"](args)["img"]
if not _in_window(args, steps):
return {"img": once}
# H3's blocks add their residual INTO the input tensor, so without a
# copy the next pass overwrites `once` and "video only" restores
# audio from an already-repeated stream (v4 shipped that bug).
h = once.clone() if video_only else once
for _ in range(times):
# A fresh dict: the caller's args must not see the intermediate.
h = extra["original_block"]({**args, "img": h})["img"]
return {"img": _video_only(args, h, once) if video_only else h}
for i in blocks:
dit_hooks.add_block_hook(patcher, key, i, repeat)
what = f"blocks {','.join(map(str, blocks))} run {times + 1}x each"
if video_only:
what += ", video only"
if steps:
what += f", {'last' if steps > 0 else 'first'} {abs(steps)} step(s)"
return what
PROVIDES = {"modifier": install}
@@ -0,0 +1,94 @@
"""Block repeat on a real H3 forward."""
import pytest
import torch
@pytest.fixture(autouse=True)
def _needs_comfy(comfyui):
"""Imports comfy."""
def _load(tiny, patcher=None, **values):
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
return FunPackLoadModifiers.execute(
patcher or tiny.patcher, {"block_repeat": {"enabled": True, **values}}).result
def _count_block_calls(tiny, patched, **run):
"""How many times each real block ran during one forward."""
counts = {}
handles = []
for i, block in enumerate(tiny.patcher.model.diffusion_model.blocks):
handles.append(block.register_forward_pre_hook(
lambda m, a, i=i: counts.__setitem__(i, counts.get(i, 0) + 1)))
try:
tiny.run(patched, **run)
finally:
for h in handles:
h.remove()
return counts
def test_each_runs_every_named_block_times_plus_one(tiny_h3):
patched, status = _load(tiny_h3, blocks="1-2", times=2)
assert "block_repeat: blocks 1,2 run 3x each" in status
assert _count_block_calls(tiny_h3, patched) == {0: 1, 1: 3, 2: 3, 3: 1}
def test_span_loops_the_range_and_runs_each_block_once_per_loop(tiny_h3):
patched, status = _load(tiny_h3, blocks="1-2", shape="span", times=1)
assert "blocks 1-2 loop 2x as one span" in status
assert _count_block_calls(tiny_h3, patched) == {0: 1, 1: 2, 2: 2, 3: 1}
def test_span_output_equals_running_the_span_twice_by_hand(tiny_h3):
"""The loop really feeds block 2's output back into block 1."""
patched, _ = _load(tiny_h3, blocks="1-2", shape="span")
looped = tiny_h3.run(patched)
# By hand: blocks 0,1,2,1,2,3 in order.
from core import dit_hooks
manual = tiny_h3.patcher.clone()
real = tiny_h3.patcher.model.diffusion_model.blocks
def twice(args, extra):
h = args["img"]
for i in (1, 2, 1, 2):
h = real[i](h, args["t_emb"], args["mod_segments"], args["rope_freqs"],
transformer_options=args["transformer_options"])
return {"img": h}
dit_hooks.add_block_hook(manual, "t", 1, twice)
dit_hooks.add_block_hook(manual, "t", 2, lambda a, e: {"img": a["img"]})
assert torch.allclose(looped[0], tiny_h3.run(manual)[0])
def test_a_scattered_list_is_refused_for_span(tiny_h3):
_p, status = _load(tiny_h3, blocks="0,2", shape="span")
assert "block_repeat: failed to install" in status
def test_step_window_repeats_only_on_the_last_steps(tiny_h3):
patched, _ = _load(tiny_h3, blocks="1", steps=1)
sched = torch.tensor([1.0, 0.7, 0.3, 0.0])
early = _count_block_calls(tiny_h3, patched, sigma=0.7, sigmas=sched)
last = _count_block_calls(tiny_h3, patched, sigma=0.3, sigmas=sched)
assert early[1] == 1 and last[1] == 2
def test_video_only_leaves_audio_as_a_single_pass_would(tiny_h3):
"""Repeating the LAST block with video only: the audio branch must match a
run with no repeat at all, the video branch must not."""
base = tiny_h3.run()
patched, _ = _load(tiny_h3, blocks="3", video_only=True)
out = tiny_h3.run(patched)
assert torch.allclose(out[1], base[1], atol=1e-6), "audio got the repeat"
assert not torch.allclose(out[0], base[0]), "video did not"
def test_video_only_span_leaves_audio_as_a_single_pass_would(tiny_h3):
base = tiny_h3.run()
patched, _ = _load(tiny_h3, blocks="2-3", shape="span", video_only=True)
out = tiny_h3.run(patched)
assert torch.allclose(out[1], base[1], atol=1e-6), "audio got the loop"
assert not torch.allclose(out[0], base[0])