Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
255 lines
14 KiB
Python
255 lines
14 KiB
Python
"""Step merging, from the settings the app saves to the finished H3 sampling run.
|
|
|
|
The app posts its module values to /fun/api/pipeline; the pipeline's settings node carries them as
|
|
one JSON string; Load Modifiers installs them on the model; FunUISampler samples with ComfyUI's own
|
|
loop. Every hop here is the real one, on ComfyUI's real MiniMaxH3 model class at a few thousand
|
|
parameters (lcm / beta (shown as beta57 in the app), 4 steps, Decisiveness and STAS active beside it, as in a real log).
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
pytest.importorskip("aiohttp")
|
|
|
|
from core.tests.test_routes_pipeline import _request, model_files, registered, server # noqa: E402,F401
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _taste_in_tmp(comfyui, tmp_path, monkeypatch):
|
|
from modules.system.taste import store
|
|
monkeypatch.setattr(store, "ROOT", tmp_path / "taste")
|
|
monkeypatch.setattr(store, "current_prompt_id", lambda: "run-1")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def model(comfyui):
|
|
import comfy.model_patcher as mp
|
|
import comfy.supported_models as sm
|
|
torch.manual_seed(0)
|
|
config = sm.MiniMaxH3(dict(
|
|
image_model="minimax_h3", hidden_size=64, num_layers=4, 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))
|
|
config.set_inference_dtype(torch.float32, None)
|
|
base = config.get_model({}, device=torch.device("cpu"))
|
|
for p in base.diffusion_model.parameters():
|
|
torch.nn.init.normal_(p, std=0.05)
|
|
p.requires_grad_(False)
|
|
return mp.ModelPatcher(base, load_device=torch.device("cpu"), offload_device=torch.device("cpu"))
|
|
|
|
|
|
APP_VALUES = {
|
|
"taste": {"key": "fox"},
|
|
"decisiveness": {"enabled": True, "mode": "manual", "level": 5},
|
|
"stas": {"enabled": True, "block": 1},
|
|
"noise_merge": {"enabled": True, "steps": "1", "order": "b_into_a", "strength": 0.5, "seed": -1},
|
|
}
|
|
|
|
|
|
def _saved_by_the_app(server, values):
|
|
status, body = _request(server, "POST", "/fun/api/pipeline", {"values": values})
|
|
assert status == 200, body
|
|
slot = next(s for s in body["slots"] if s["node"] == "FunUIModifierSettings")
|
|
return slot["inputs"]["settings"]
|
|
|
|
|
|
def _sample(model, settings, sampler="lcm", scheduler="beta", steps=4):
|
|
from comfy.nested_tensor import NestedTensor
|
|
from modules.sampling.modifiers.nodes import FunUILoadModifiers, FunUIModifierSettings
|
|
from modules.sampling.sampler.nodes import FunUISampler
|
|
checked = FunUIModifierSettings.execute(settings).result[0]
|
|
patched, status = FunUILoadModifiers.execute(model, checked).result
|
|
forwards = []
|
|
dm = patched.model.diffusion_model
|
|
hook = dm.register_forward_hook(lambda *a: forwards.append(1))
|
|
cond = [[torch.randn(1, 5, 48), {}]]
|
|
latent = {"samples": NestedTensor((torch.zeros(1, 24, 2, 4, 4), torch.zeros(1, 32, 2, 3)))}
|
|
try:
|
|
out = FunUISampler.execute(patched, cond, cond, latent, seed=1, steps=steps, cfg=1.0,
|
|
sampler_name=sampler, scheduler=scheduler, denoise=1.0)
|
|
finally:
|
|
hook.remove()
|
|
assert all(torch.isfinite(p).all() for p in out.result[0]["samples"].unbind())
|
|
return status, len(forwards)
|
|
|
|
|
|
def test_noise_merging_shows_in_the_manifest_next_to_the_other_sampling_settings(server):
|
|
status, manifest = _request(server, "GET", "/fun/api/modules")
|
|
mods = {m["id"]: m for m in manifest["modules"]}
|
|
assert "noise_merge" in mods and mods["noise_merge"]["mount"] == mods["decisiveness"]["mount"]
|
|
assert {"enabled", "steps", "order", "strength", "seed"} <= set(mods["noise_merge"]["settings"])
|
|
|
|
|
|
def test_what_the_app_saves_reaches_the_h3_sampler_and_adds_one_pass_at_step_one(server, model):
|
|
from core import log
|
|
saved = _saved_by_the_app(server, APP_VALUES)
|
|
assert json.loads(saved)["noise_merge"]["steps"] == "1"
|
|
off = json.loads(saved)
|
|
off["noise_merge"]["enabled"] = False
|
|
_s, plain = _sample(model, json.dumps(off))
|
|
log.new_run()
|
|
status, merged = _sample(model, saved)
|
|
assert "noise_merge" in status and "decisiveness" in status and "stas" in status
|
|
assert merged == plain + 1
|
|
texts = [e["message"] for e in log.history()]
|
|
assert any(t.startswith("on: steps 1") for t in texts)
|
|
assert any(t.startswith("step 1: second answer differs") for t in texts)
|
|
|
|
|
|
def test_a_second_run_on_the_same_cached_model_still_makes_the_extra_pass(server, model):
|
|
"""ComfyUI hands the SAME patched model to every run whose Load Modifiers inputs did not change."""
|
|
from comfy.nested_tensor import NestedTensor
|
|
from core import log
|
|
from modules.sampling.modifiers.nodes import FunUILoadModifiers, FunUIModifierSettings
|
|
from modules.sampling.sampler.nodes import FunUISampler
|
|
saved = _saved_by_the_app(server, APP_VALUES)
|
|
patched, _ = FunUILoadModifiers.execute(model, FunUIModifierSettings.execute(saved).result[0]).result
|
|
counts = []
|
|
for seed in (1, 2, 3):
|
|
forwards = []
|
|
hook = patched.model.diffusion_model.register_forward_hook(lambda *a: forwards.append(1))
|
|
cond = [[torch.randn(1, 5, 48), {}]]
|
|
latent = {"samples": NestedTensor((torch.zeros(1, 24, 2, 4, 4), torch.zeros(1, 32, 2, 3)))}
|
|
before = sum("second pass took" in e["message"] for e in log.history())
|
|
try:
|
|
FunUISampler.execute(patched, cond, cond, latent, seed=seed, steps=4, cfg=1.0,
|
|
sampler_name="lcm", scheduler="beta", denoise=1.0)
|
|
finally:
|
|
hook.remove()
|
|
counts.append((len(forwards), sum("second pass took" in e["message"] for e in log.history()) - before))
|
|
assert counts == [(5, 1)] * 3, counts
|
|
|
|
|
|
def test_merging_the_starting_noise_costs_no_extra_pass_and_changes_the_video(server, model):
|
|
values = dict(APP_VALUES, noise_merge={"enabled": True, "what": "noise", "strength": 0.5, "seed": 7})
|
|
off = dict(APP_VALUES, noise_merge={"enabled": False})
|
|
from core import log
|
|
status, merged = _sample(model, _saved_by_the_app(server, values))
|
|
assert any("starting noise mixed with the second seed's (7)" in e["message"] for e in log.history())
|
|
_s, plain = _sample(model, _saved_by_the_app(server, off))
|
|
assert "starting noise" in status and merged == plain
|
|
|
|
|
|
def test_a_second_run_through_the_h3_sampler_adds_one_pass_per_step_up_to_the_last_named(server, model):
|
|
values = dict(APP_VALUES, noise_merge={"enabled": True, "what": "parallel", "steps": "2-3", "strength": 0.5, "seed": 7})
|
|
off = dict(APP_VALUES, noise_merge={"enabled": False})
|
|
status, merged = _sample(model, _saved_by_the_app(server, values))
|
|
_s, plain = _sample(model, _saved_by_the_app(server, off))
|
|
assert merged == plain + 3
|
|
|
|
|
|
def test_simultaneous_actions_run_through_the_real_h3_model_where_the_prompt_is_refined_before_sampling(server, model):
|
|
"""The model refines the prompt once per run (text_dim 48 -> hidden 64), so an option's prompt must go
|
|
through the same step: the sampler's conditioning is not the encoder's output."""
|
|
import importlib
|
|
from comfy.nested_tensor import NestedTensor
|
|
from core import log
|
|
from modules.conditioning.prompt_markup.nodes import FunUIApplyPromptMarkup, FunUIPromptMarkup
|
|
from modules.sampling.modifiers.nodes import FunUILoadModifiers, FunUIModifierSettings
|
|
from modules.sampling.sampler.nodes import FunUISampler
|
|
clip = importlib.import_module("modules.conditioning.prompt_markup.tests.test_prompt_markup").FakeH3Clip()
|
|
values = dict(APP_VALUES, noise_merge={"enabled": True, "steps": "2", "strength": 0.5, "seed": 3},
|
|
prompt_alternatives={"separate": True})
|
|
checked = FunUIModifierSettings.execute(_saved_by_the_app(server, values)).result[0]
|
|
patched, _ = FunUILoadModifiers.execute(model, checked).result
|
|
clean, markup, _ = FunUIPromptMarkup.execute("a girl [dances|zooms in] at dusk", checked).result
|
|
positive = clip.encode_from_tokens_scheduled(clip.tokenize(clean))
|
|
patched, positive, status = FunUIApplyPromptMarkup.execute(
|
|
patched, clip, positive, {"samples": NestedTensor((torch.zeros(1, 24, 2, 4, 4), torch.zeros(1, 32, 2, 3)))}, markup).result
|
|
assert status == "2 simultaneous option(s)"
|
|
forwards = []
|
|
hook = patched.model.diffusion_model.register_forward_pre_hook(
|
|
lambda m, a, k: forwards.append((a[2] if len(a) > 2 else k["context"]).detach().clone()), with_kwargs=True)
|
|
log._reset() # only this run's messages are looked at below
|
|
latent = {"samples": NestedTensor((torch.zeros(1, 24, 2, 4, 4), torch.zeros(1, 32, 2, 3)))}
|
|
try:
|
|
out = FunUISampler.execute(patched, positive, positive, latent, seed=1, steps=4, cfg=1.0,
|
|
sampler_name="lcm", scheduler="beta", denoise=1.0)
|
|
finally:
|
|
hook.remove()
|
|
assert len(forwards) == 5 # 4 steps; the named one is answered once per action
|
|
assert all(c.shape[-1] == 64 for c in forwards) # every call holds the refined prompt, option or not
|
|
assert not torch.allclose(forwards[1], forwards[2]) # the two answers of step 2 read different prompts
|
|
assert torch.allclose(forwards[0], forwards[3]) and torch.allclose(forwards[3], forwards[4])
|
|
assert all(torch.isfinite(p).all() for p in out.result[0]["samples"].unbind())
|
|
texts = [e["message"] for e in log.history()]
|
|
assert any(t.startswith("step 2: both actions answered separately") for t in texts), texts
|
|
assert not any("failed while merging" in t or "Simultaneous actions skipped" in t for t in texts), texts
|
|
|
|
|
|
def test_alternative_prompts_read_a_different_option_on_each_assigned_step_on_the_real_h3_model(server, model):
|
|
"""One call per step as usual; steps 1-2 read A, step 3 reads B, the rest the main prompt."""
|
|
import importlib
|
|
from comfy.nested_tensor import NestedTensor
|
|
from modules.conditioning.prompt_markup.nodes import FunUIApplyPromptMarkup, FunUIPromptMarkup
|
|
from modules.sampling.modifiers.nodes import FunUILoadModifiers, FunUIModifierSettings
|
|
from modules.sampling.sampler.nodes import FunUISampler
|
|
clip = importlib.import_module("modules.conditioning.prompt_markup.tests.test_prompt_markup").FakeH3Clip()
|
|
values = dict(APP_VALUES, noise_merge={"enabled": False},
|
|
prompt_alternatives={"separate": True, "main": "while", "a_steps": "1-2", "b_steps": "3"})
|
|
checked = FunUIModifierSettings.execute(_saved_by_the_app(server, values)).result[0]
|
|
patched, status = FunUILoadModifiers.execute(model, checked).result
|
|
assert "prompt_alternatives" in status
|
|
clean, markup, _ = FunUIPromptMarkup.execute("a girl [dances|zooms in] at dusk", checked).result
|
|
positive = clip.encode_from_tokens_scheduled(clip.tokenize(clean))
|
|
shapes = NestedTensor((torch.zeros(1, 24, 2, 4, 4), torch.zeros(1, 32, 2, 3)))
|
|
patched, positive, _ = FunUIApplyPromptMarkup.execute(patched, clip, positive, {"samples": shapes}, markup).result
|
|
seen = []
|
|
hook = patched.model.diffusion_model.register_forward_pre_hook(
|
|
lambda m, a, k: seen.append((a[2] if len(a) > 2 else k["context"]).detach().clone()), with_kwargs=True)
|
|
try:
|
|
FunUISampler.execute(patched, positive, positive, {"samples": shapes}, seed=1, steps=5, cfg=1.0,
|
|
sampler_name="lcm", scheduler="beta", denoise=1.0)
|
|
finally:
|
|
hook.remove()
|
|
assert len(seen) == 5 # no extra pass anywhere
|
|
assert torch.allclose(seen[0], seen[1]) and not torch.allclose(seen[1], seen[2]) # A, A, then B
|
|
assert torch.allclose(seen[3], seen[4]) and not torch.allclose(seen[3], seen[0]) # the main prompt after
|
|
assert not torch.allclose(seen[3], seen[2])
|
|
|
|
|
|
def test_a_merged_step_wins_over_an_assigned_step(server, model):
|
|
"""Step 2 is both merged (A and B answered) and assigned to B: the merge still answers A then B."""
|
|
import importlib
|
|
from comfy.nested_tensor import NestedTensor
|
|
from modules.conditioning.prompt_markup.nodes import FunUIApplyPromptMarkup, FunUIPromptMarkup
|
|
from modules.sampling.modifiers.nodes import FunUILoadModifiers, FunUIModifierSettings
|
|
from modules.sampling.sampler.nodes import FunUISampler
|
|
clip = importlib.import_module("modules.conditioning.prompt_markup.tests.test_prompt_markup").FakeH3Clip()
|
|
values = dict(APP_VALUES, noise_merge={"enabled": True, "steps": "2", "fresh": 0.0},
|
|
prompt_alternatives={"separate": True, "b_steps": "2"})
|
|
checked = FunUIModifierSettings.execute(_saved_by_the_app(server, values)).result[0]
|
|
patched, _ = FunUILoadModifiers.execute(model, checked).result
|
|
clean, markup, _ = FunUIPromptMarkup.execute("a girl [dances|zooms in] at dusk", checked).result
|
|
positive = clip.encode_from_tokens_scheduled(clip.tokenize(clean))
|
|
shapes = NestedTensor((torch.zeros(1, 24, 2, 4, 4), torch.zeros(1, 32, 2, 3)))
|
|
patched, positive, _ = FunUIApplyPromptMarkup.execute(patched, clip, positive, {"samples": shapes}, markup).result
|
|
seen = []
|
|
hook = patched.model.diffusion_model.register_forward_pre_hook(
|
|
lambda m, a, k: seen.append((a[2] if len(a) > 2 else k["context"]).detach().clone()), with_kwargs=True)
|
|
try:
|
|
FunUISampler.execute(patched, positive, positive, {"samples": shapes}, seed=1, steps=4, cfg=1.0,
|
|
sampler_name="lcm", scheduler="beta", denoise=1.0)
|
|
finally:
|
|
hook.remove()
|
|
assert len(seen) == 5 and not torch.allclose(seen[1], seen[2]) # step 2 made two calls: A, then B
|
|
|
|
|
|
def test_alternating_prompts_say_why_they_did_nothing(server, model):
|
|
"""No [A|B] in the prompt, or no named step in the run: two different sentences."""
|
|
from comfy.nested_tensor import NestedTensor
|
|
from core import log
|
|
from modules.sampling.modifiers.nodes import FunUILoadModifiers, FunUIModifierSettings
|
|
from modules.sampling.sampler.nodes import FunUISampler
|
|
shapes = NestedTensor((torch.zeros(1, 24, 2, 4, 4), torch.zeros(1, 32, 2, 3)))
|
|
cond = [[torch.randn(1, 5, 48), {}]]
|
|
checked = FunUIModifierSettings.execute(_saved_by_the_app(server, dict(
|
|
APP_VALUES, noise_merge={"enabled": False}, prompt_alternatives={"separate": True, "b_steps": "1"}))).result[0]
|
|
patched, _ = FunUILoadModifiers.execute(model, checked).result
|
|
log._reset()
|
|
FunUISampler.execute(patched, cond, cond, {"samples": shapes}, seed=1, steps=3, cfg=1.0,
|
|
sampler_name="lcm", scheduler="beta", denoise=1.0)
|
|
assert any("the prompt had no [A|B] options" in e["message"] for e in log.history())
|