Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
367 lines
20 KiB
Python
367 lines
20 KiB
Python
"""Step merging: at chosen steps, run the step a second time on the same picture with
|
|
different noise and blend the two answers.
|
|
|
|
Step N is answered twice. Picture A is the run's own answer. Picture B is the answer to
|
|
the same step after A's picture is re-noised to this step's noise level with a second
|
|
seed (and so is the sound, when the model has it). The two are then mixed:
|
|
|
|
merged = base + strength * (other - base)
|
|
|
|
`Merge order` picks the base. "B into A" keeps A's picture and lets B's variation in;
|
|
"A into B" starts from B and lets A in. Two independent answers averaged lose contrast
|
|
(the independent detail partly cancels), so the mix is rescaled to the base's own spread.
|
|
|
|
"Second run" keeps a whole second video going beside yours: its first input is the second seed's noise and every
|
|
later input is its own last answer re-noised, the way a few-step sampler chains its steps. At the named steps its
|
|
answer is blended into yours the same way. It costs one extra pass at every step up to the last one named.
|
|
|
|
"Simultaneous actions": with `[A|B]` in the prompt (or `A|B` between two shortcuts) and Alternating prompts >
|
|
"Read A and B separately" on, the named steps are answered
|
|
once for each option -- the same picture and noise, a prompt that holds only that option -- and the answers are
|
|
merged the same way, instead of the second-seed pass. The prompt outside those steps holds both ("A while B").
|
|
|
|
Costs one extra model pass per merged step. The second pass is marked as a probe, so no
|
|
learning feature records it.
|
|
"""
|
|
|
|
import random
|
|
import time
|
|
|
|
import math
|
|
|
|
import torch
|
|
from comfy.model_management import InterruptProcessingException
|
|
from comfy.patcher_extension import WrappersMP
|
|
|
|
from ..._core import dit_hooks, log, streams
|
|
|
|
ID = "noise_merge"
|
|
TITLE = "Step merging"
|
|
MOUNT = "generation.sampling"
|
|
STAGE = "sampling"
|
|
CATEGORY = "sampling"
|
|
STATUS = "experimental"
|
|
REQUIRES = ["temporal_latent"]
|
|
|
|
SETTINGS = {
|
|
"enabled": {
|
|
"type": "bool", "default": False,
|
|
"label": "Step merging",
|
|
"hint": "At the steps below, answers the step twice with different noise and blends the two. Costs one extra pass per merged step.",
|
|
},
|
|
"what": {
|
|
"type": "enum", "default": "answers", "options": [
|
|
{"value": "answers", "label": "Step answers"},
|
|
{"value": "parallel", "label": "Second run"},
|
|
{"value": "noise", "label": "Starting noise"},
|
|
],
|
|
"label": "Merge", "ui": "segmented",
|
|
"hint": "Blends in a second seed. Step answers: at chosen steps. Second run: a whole second video (slower). Starting noise: free.",
|
|
"when": {"enabled": True},
|
|
},
|
|
"steps": {
|
|
"type": "text", "default": "1",
|
|
"label": "Steps",
|
|
"hint": "Step numbers from 1, e.g. 1 or 2-3 or 1,4. Steps past the end of the schedule are ignored.",
|
|
"when": {"enabled": True, "what": ["answers", "parallel"]},
|
|
},
|
|
"order": {
|
|
"type": "enum", "default": "b_into_a", "options": [
|
|
{"value": "b_into_a", "label": "B into A"},
|
|
{"value": "a_into_b", "label": "A into B"},
|
|
],
|
|
"label": "Merge order", "ui": "segmented",
|
|
"hint": "A is your seed's picture, B the same step on a second seed. The first one named is blended in; the other is the base.",
|
|
"when": {"enabled": True, "what": ["answers", "parallel"]},
|
|
},
|
|
"strength": {
|
|
"type": "float", "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05,
|
|
"label": "Merge strength", "ui": "slider",
|
|
"hint": "0 keeps your own result untouched, 1 replaces it with the second seed's. For starting noise, 0.5 is an even mix.",
|
|
"when": {"enabled": True},
|
|
},
|
|
"fresh": {
|
|
"type": "float", "default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05,
|
|
"label": "Second pass new noise", "ui": "slider",
|
|
"hint": "How much of the step's noise the second pass swaps for the second seed's. 1: all of it. 0: no change.",
|
|
"when": {"enabled": True, "what": "answers"},
|
|
},
|
|
"sound": {
|
|
"type": "bool", "default": True,
|
|
"label": "Merge sound too",
|
|
"hint": "Blends the sound the same way as the picture. Off keeps only the base's own sound.",
|
|
"when": {"enabled": True, "what": ["answers", "parallel"]},
|
|
},
|
|
"seed": {
|
|
"type": "int", "default": -1, "min": -1, "max": 2147483647,
|
|
"label": "Second seed",
|
|
"hint": "The noise for picture B (or the second noise). -1 picks a new one every run; a number gives the same B each time.",
|
|
"when": {"enabled": True},
|
|
},
|
|
}
|
|
|
|
|
|
def _say(message, level=log.ALERT):
|
|
log.once(f"{ID}:{message}", level, "FunUI Step merging", message)
|
|
|
|
|
|
def merge(base, other, strength):
|
|
"""base + strength * (other - base), rescaled so each clip keeps the base's spread
|
|
(std around its own mean) -- a plain mix of two independent answers is flatter."""
|
|
mixed = base + strength * (other - base)
|
|
dims = tuple(range(1, base.dim()))
|
|
base_std = base.float().std(dim=dims, keepdim=True)
|
|
mixed_std = mixed.float().std(dim=dims, keepdim=True).clamp(min=1e-8)
|
|
mean = mixed.float().mean(dim=dims, keepdim=True)
|
|
return ((mixed.float() - mean) * (base_std / mixed_std) + mean).to(base.dtype)
|
|
|
|
|
|
def second_input(x, a, sigma, seed, step, fresh):
|
|
"""The input for picture B. Rectified flow: x = (1 - sigma) picture + sigma noise. A's answer is the picture;
|
|
the noise is the step's own noise (what is left of x once A's picture is taken out) turned `fresh` of the
|
|
way toward the second seed's noise, at unit variance. fresh 1 is all second-seed noise, 0 hands back x."""
|
|
if sigma < 1e-4:
|
|
return x
|
|
own = (x.float() - (1.0 - sigma) * a.float()) / sigma
|
|
gen = torch.Generator().manual_seed(int(seed) + 7919 * int(step))
|
|
other = torch.randn(x.shape, generator=gen).to(device=x.device)
|
|
theta = fresh * torch.pi / 2
|
|
noise = float(torch.cos(torch.tensor(theta))) * own + float(torch.sin(torch.tensor(theta))) * other
|
|
return ((1.0 - sigma) * a.float() + sigma * noise).to(x.dtype)
|
|
|
|
|
|
SIMUL = "funui_simultaneous" # what Prompt markup leaves in transformer_options: {"options", "cond_len"}
|
|
|
|
|
|
def _mix(out_a, out_b, named, strength, b_first, with_sound):
|
|
"""Two full answers of one step -> (merged answer, how far apart they were), or None when either has no picture."""
|
|
split_a, split_b = streams.video_of(out_a, named), streams.video_of(out_b, named)
|
|
if split_a is None or split_b is None:
|
|
return None
|
|
(video_a, rebuild_a), (video_b, rebuild_b) = split_a, split_b
|
|
tail_a, tail_b = _sound(out_a, video_a), _sound(out_b, video_b)
|
|
diff = float((video_a - video_b).norm() / video_a.norm().clamp(min=1e-8))
|
|
if b_first:
|
|
base, other, base_tail, other_tail, rebuild = video_a, video_b, tail_a, tail_b, rebuild_a
|
|
else:
|
|
base, other, base_tail, other_tail, rebuild = video_b, video_a, tail_b, tail_a, rebuild_b
|
|
merged = rebuild(merge(base, other, strength).to(base.dtype))
|
|
if with_sound and base_tail is not None and other_tail is not None:
|
|
n = merged.shape[-1] - base_tail.shape[-1]
|
|
merged = torch.cat([merged[..., :n], merge(base_tail, other_tail, strength).to(merged.dtype)], dim=-1)
|
|
return merged, diff
|
|
|
|
|
|
def _second_x(x, out, named, sigma, seed, step, fresh, with_sound):
|
|
"""The input for a second pass over `x` after `out` answered it: the step's noise turned `fresh` of the way toward
|
|
the second seed's noise around out's picture (and sound), on x's own device and dtype. x itself when out has no picture."""
|
|
split_x, split_a = streams.video_of(x, named), streams.video_of(out, named)
|
|
if split_x is None or split_a is None:
|
|
return x
|
|
video_a, tail_a = split_a[0].detach(), _sound(out, split_a[0])
|
|
x_b = split_x[1](second_input(split_x[0], video_a, sigma, seed, step, fresh))
|
|
if tail_a is not None and with_sound:
|
|
n = x_b.shape[-1] - tail_a.shape[-1]
|
|
x_b = torch.cat([x_b[..., :n], second_input(x[..., n:], tail_a.detach(), sigma, seed + 1, step, fresh)], dim=-1)
|
|
return x_b
|
|
|
|
|
|
def _sound(packed, video):
|
|
"""The part of a packed [B, 1, N] result that follows the picture (H3's sound), or None."""
|
|
if getattr(packed, "dim", None) is None or packed.dim() != 3:
|
|
return None
|
|
n = video[0].numel()
|
|
return packed[..., n:] if packed.shape[-1] > n else None
|
|
|
|
|
|
def _resized(video, shape):
|
|
"""`video` [B, C, T, H, W] brought to `shape` (only the picture's height and width may differ), or None."""
|
|
if video.dim() != 5 or len(shape) != 5 or tuple(video.shape[:3]) != tuple(shape[:3]):
|
|
return None
|
|
return torch.nn.functional.interpolate(video.float(), size=tuple(shape[2:]), mode="trilinear",
|
|
align_corners=False).to(device=video.device, dtype=video.dtype)
|
|
|
|
|
|
def mix_noise(noise, other, strength):
|
|
"""cos(t) noise + sin(t) other, t = strength * 90 degrees: two independent unit-variance noises
|
|
stay unit variance (a plain average would shrink to 71% at an even mix, and the model reads that as a calmer, blurrier start)."""
|
|
theta = strength * torch.pi / 2
|
|
return (math.cos(theta) * noise + math.sin(theta) * other.to(device=noise.device, dtype=noise.dtype)).to(noise.dtype) # plain numbers: no tensor to put on a device
|
|
|
|
|
|
def install_noise(patcher, values, key):
|
|
strength = float(values.get("strength", 0.5))
|
|
if strength <= 0.0:
|
|
_say("off: strength is 0")
|
|
return None
|
|
chosen = int(values.get("seed", -1))
|
|
run_seed = {}
|
|
|
|
def sampler_sample(executor, model_wrap, sigmas, extra_args, callback, noise,
|
|
latent_image=None, denoise_mask=None, disable_pbar=False):
|
|
if not isinstance(noise, torch.Tensor):
|
|
_say("off this run: the starting noise is not a plain tensor")
|
|
return executor(model_wrap, sigmas, extra_args, callback, noise, latent_image, denoise_mask, disable_pbar)
|
|
second_stage = dit_hooks.stage_of(model_wrap) == 2 # Two stages: both stages mix with the SAME second seed
|
|
if not second_stage or "seed" not in run_seed:
|
|
run_seed["seed"] = chosen if chosen >= 0 else random.randrange(2 ** 31)
|
|
seed = run_seed["seed"]
|
|
other = torch.randn(noise.shape, generator=torch.Generator().manual_seed(seed)).to(noise)
|
|
if not second_stage:
|
|
log.info("FunUI Step merging", f"on: starting noise mixed with the second seed's ({seed}) at "
|
|
f"strength {strength:.2f}, no extra pass")
|
|
return executor(model_wrap, sigmas, extra_args, callback, mix_noise(noise, other, strength),
|
|
latent_image, denoise_mask, disable_pbar)
|
|
|
|
patcher.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, key, sampler_sample)
|
|
return f"starting noise, strength {strength:.2f}, second seed {'random' if chosen < 0 else chosen}"
|
|
|
|
|
|
def install(patcher, values, key):
|
|
if not values.get("enabled"):
|
|
return None
|
|
if values.get("what") == "noise":
|
|
return install_noise(patcher, values, key)
|
|
wanted, problems = dit_hooks.parse_blocks(values.get("steps"), 100000)
|
|
wanted = {i - 1 for i in wanted if i >= 1}
|
|
for p in problems:
|
|
_say(f"ignored step entry: {p}")
|
|
strength = float(values.get("strength", 0.5))
|
|
if not wanted:
|
|
_say("off: no valid steps named")
|
|
return None
|
|
b_first = values.get("order", "b_into_a") == "b_into_a"
|
|
with_sound = bool(values.get("sound", True))
|
|
fresh = float(values.get("fresh", 1.0))
|
|
parallel = values.get("what") == "parallel"
|
|
last = max(wanted)
|
|
chosen = int(values.get("seed", -1))
|
|
live = {"seed": max(chosen, 0), "merged": 0, "prev": None, "options": {}}
|
|
|
|
def start(executor, *args, **kwargs):
|
|
# Two stages is two sampler calls of one run: merges counted in the first still count, and the verdict comes after the last.
|
|
stage = dit_hooks.stage_of(getattr(executor, "class_obj", None))
|
|
live["seed"] = chosen if chosen >= 0 else random.randrange(2 ** 31)
|
|
live["prev"], live["options"] = None, {}
|
|
if stage != 2:
|
|
live["merged"] = 0
|
|
log.info("FunUI Step merging", f"on: {'second run, ' if parallel else ''}steps {values.get('steps')}, strength {strength:.2f}, "
|
|
f"second seed {live['seed']}")
|
|
try:
|
|
return executor(*args, **kwargs)
|
|
finally:
|
|
live["options"].clear()
|
|
if stage != 1 and not live["merged"]:
|
|
_say("Inactive | no step of this run matched the steps you named "
|
|
"(or the model was not called as a plain prompt step)")
|
|
|
|
patcher.add_wrapper_with_key(WrappersMP.OUTER_SAMPLE, key, start)
|
|
|
|
def apply_model(executor, x, t, *args, **kwargs):
|
|
named = streams.model_args(args, kwargs)
|
|
to = named.get("transformer_options") or {}
|
|
where = dit_hooks.current_step(to)
|
|
simul = to.get(SIMUL) if not parallel else None
|
|
context = named.get("c_crossattn")
|
|
if (simul and where is not None and where[0] in wanted and not dit_hooks.probing(to)
|
|
and all(int(i) == 0 for i in (to.get("cond_or_uncond") or [0]))):
|
|
if (isinstance(context, torch.Tensor) and context.dim() == 3
|
|
and int(context.shape[1]) == simul["cond_len"] and len(simul["options"]) > 1):
|
|
try:
|
|
# Strength 0 keeps the base's answer and strength 1 (with fully new noise, so the second run does
|
|
# not start from the first one's picture) is the other's alone: that one is the only pass made.
|
|
base_k, other_k = (0, 1) if b_first else (1, 0)
|
|
only = base_k if strength <= 0.0 else other_k if strength >= 1.0 and fresh >= 1.0 else None
|
|
outs, x_k = {}, x
|
|
for k in ((only,) if only is not None else (0, 1)):
|
|
args_k, kwargs_k = streams.with_context(args, kwargs, streams.option_context(
|
|
getattr(executor, "class_obj", None), simul["options"][k], context, live["options"], k))
|
|
args_k, kwargs_k = streams.with_options(args_k, kwargs_k, {**to, streams.OPTION_PINNED: True})
|
|
outs[k] = executor(x_k, t, *args_k, **kwargs_k)
|
|
if k == 0 and fresh > 0.0 and only is None:
|
|
# The second option starts from partly new noise: the step's own noise, turned `fresh`
|
|
# of the way toward the second seed's, around the first option's picture.
|
|
x_k = _second_x(x, outs[0], named, float(t.max()), live["seed"], where[0], fresh, with_sound)
|
|
if only is not None:
|
|
live["merged"] += 1
|
|
log.info("FunUI Step merging", f"step {where[0] + 1}: only {'AB'[only]} read "
|
|
f"(strength {strength:.2f}, new noise {fresh:.2f}), nothing to merge")
|
|
return outs[only]
|
|
mixed = _mix(outs[0], outs[1], named, strength, b_first, with_sound)
|
|
if mixed is not None:
|
|
live["merged"] += 1
|
|
log.info("FunUI Step merging", f"step {where[0] + 1}: both actions answered separately, the "
|
|
f"answers differ by {mixed[1]:.1%}, merged at {strength:.2f}")
|
|
return mixed[0]
|
|
except InterruptProcessingException:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001
|
|
log.broke("FunUI Step merging", exc, "merging the simultaneous actions")
|
|
return executor(x, t, *args, **kwargs)
|
|
else:
|
|
_say("Simultaneous actions skipped: another feature changed the prompt's conditioning")
|
|
out = executor(x, t, *args, **kwargs)
|
|
if strength <= 0.0 or where is None or dit_hooks.probing(to) or where[0] > last or (where[0] not in wanted and not parallel):
|
|
return out # strength 0 keeps your own answer: no second pass to blend in
|
|
if any(int(i) != 0 for i in (to.get("cond_or_uncond") or [0])):
|
|
return out # a negative-prompt call: guidance is for the prompt's picture
|
|
try:
|
|
split_x, split_a = streams.video_of(x, named), streams.video_of(out, named)
|
|
if split_x is None or split_a is None:
|
|
_say("off this run: could not find the picture in this model's latent")
|
|
return out
|
|
sigma = float(t.max())
|
|
video_a, rebuild_a = split_a
|
|
tail_a = _sound(out, video_a)
|
|
gen_step = where[0]
|
|
if parallel:
|
|
# The second run: each step's input is its own last answer (zero at the start) re-noised with the
|
|
# second seed's noise, the way a few-step sampler chains its steps.
|
|
prev = live["prev"] or (torch.zeros_like(video_a), torch.zeros_like(tail_a) if tail_a is not None else None)
|
|
if prev[0].shape != video_a.shape:
|
|
prev = (_resized(prev[0], video_a.shape), prev[1]) # Two stages: the second run moves to the full size too
|
|
if prev[0] is None:
|
|
_say("Inactive | the latent changed size during the run, so the second run was dropped")
|
|
return out
|
|
base_v, base_t, mix = prev[0], prev[1], 1.0
|
|
else:
|
|
base_v, base_t, mix = video_a.detach(), tail_a, fresh
|
|
x_b = split_x[1](second_input(split_x[0], base_v, sigma, live["seed"], gen_step, mix))
|
|
if tail_a is not None and with_sound:
|
|
n = x_b.shape[-1] - tail_a.shape[-1]
|
|
x_b = torch.cat([x_b[..., :n], second_input(x[..., n:], base_t.detach(), sigma, live["seed"] + 1,
|
|
gen_step, mix)], dim=-1)
|
|
args_b, kwargs_b = streams.with_options(args, kwargs, {**to, dit_hooks.PROBE: True})
|
|
began = time.perf_counter()
|
|
out_b = executor(x_b, t, *args_b, **kwargs_b)
|
|
took = time.perf_counter() - began
|
|
split_b = streams.video_of(out_b, named)
|
|
if split_b is None:
|
|
return out
|
|
video_b, rebuild_b = split_b
|
|
tail_b = _sound(out_b, video_b)
|
|
if parallel:
|
|
live["prev"] = (video_b.detach(), None if tail_b is None else tail_b.detach())
|
|
if gen_step not in wanted:
|
|
return out
|
|
live["merged"] += 1
|
|
diff = float((video_a - video_b).norm() / video_a.norm().clamp(min=1e-8))
|
|
log.info("FunUI Step merging",
|
|
f"step {where[0] + 1}: second answer differs from the first by {diff:.1%}, "
|
|
f"merged at {strength:.2f}{' (picture and sound)' if tail_a is not None and with_sound else ''}; "
|
|
f"the second pass took {took:.1f}s")
|
|
mixed = _mix(out, out_b, named, strength, b_first, with_sound)
|
|
return out if mixed is None else mixed[0]
|
|
except InterruptProcessingException:
|
|
raise # Cancel stops the run, it is not a failed merge
|
|
except Exception as exc: # noqa: BLE001
|
|
log.broke("FunUI Step merging", exc, "merging the second answer")
|
|
return out
|
|
|
|
patcher.add_wrapper_with_key(WrappersMP.APPLY_MODEL, key, apply_model)
|
|
return (f"steps {values.get('steps')}, {'B into A' if b_first else 'A into B'}, "
|
|
f"strength {strength:.2f}, second seed {'random' if chosen < 0 else chosen}")
|
|
|
|
|
|
PROVIDES = {"modifier": install}
|