From b1169d123df7eecc4da917d90d2d695870003253 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:25:54 +0200 Subject: [PATCH] Simultaneous actions: the Second pass new noise slider sets how much of the second option's starting noise is new Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- modules/sampling/noise_merge/__init__.py | 24 ++++++++++++++++--- .../noise_merge/tests/test_noise_merge.py | 20 ++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d831b44..94bc19af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ What is new in each version, in plain words. Newest first. ## [Unreleased] - **Noise merging is now called Step merging**, because most of its modes merge the model's answers at a step, not noise. Settings are kept. -- **Simultaneous actions** (Step merging): write `[dance|zoom]` in a prompt, or `DANCE|ZOOM` between two shortcuts, for two things that happen at the same time. At the steps you name, each action is answered on its own prompt from the same picture and noise, and the two answers are merged at your strength. The other steps read both ("dance while zoom"). Turned on by default inside Noise merging; costs one extra model pass per merged step. A `|` next to anything that is not a shortcut is left as typed. Two options per bracket. +- **Simultaneous actions** (Step merging): write `[dance|zoom]` in a prompt, or `DANCE|ZOOM` between two shortcuts, for two things that happen at the same time. At the steps you name, each action is answered on its own prompt from the same picture, and the two answers are merged at your strength. "Second pass new noise" sets how much of the noise the second action starts from is new (0 keeps the same noise for both). The other steps read both ("dance while zoom"). Turned on by default inside Noise merging; costs one extra model pass per merged step. A `|` next to anything that is not a shortcut is left as typed. Two options per bracket. - **Removed: timed phrases `[phrase@2-4]`, and `[a|b]` as a blend of two prompts.** `[a|b]` now means two actions at once (above). `(word:1.5)` weights work as before. - **Reference sheet** (Assets panel, "Reference sheet..."): pick rows and columns, a picture and a label for each slot, and a title. You get one white sheet with the pictures in a grid, each labelled below and the title in the top left corner, saved to the media bin as a picture. Pictures come from the bin or an upload; each is cropped from the middle to the slot's shape. diff --git a/modules/sampling/noise_merge/__init__.py b/modules/sampling/noise_merge/__init__.py index 9a290977..6704f669 100644 --- a/modules/sampling/noise_merge/__init__.py +++ b/modules/sampling/noise_merge/__init__.py @@ -88,7 +88,7 @@ SETTINGS = { "actions": { "type": "bool", "default": True, "label": "Simultaneous actions", - "hint": "With [A|B] in the prompt, answers the steps above once for A and once for B and merges them, so both happen at once. Costs one extra pass per step.", + "hint": "With [A|B] in the prompt, answers the steps above once for each option and merges them, so both happen at once. One extra pass per step.", "when": {"enabled": True, "what": "answers"}, }, "sound": { @@ -157,6 +157,20 @@ def _mix(out_a, out_b, named, strength, b_first, with_sound): 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 _option_context(base, simul, k, like, cache): """Option k's whole conditioning, put through the model's own text preparation (H3 refines the prompt once per run, so the sampler's conditioning is no longer the encoder's output), on the device and dtype of `like`.""" @@ -270,12 +284,16 @@ def install(patcher, values, key): if (isinstance(context, torch.Tensor) and context.dim() == 3 and int(context.shape[1]) == simul["cond_len"] and len(simul["options"]) > 1): try: - outs = [] + outs, x_k = [], x for k in (0, 1): args_k, kwargs_k = streams.with_context( args, kwargs, _option_context(getattr(executor, "class_obj", None), simul, k, context, live["options"])) - outs.append(executor(x, t, *args_k, **kwargs_k)) + outs.append(executor(x_k, t, *args_k, **kwargs_k)) + if k == 0 and fresh > 0.0: + # 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) mixed = _mix(outs[0], outs[1], named, strength, b_first, with_sound) if mixed is not None: live["merged"] += 1 diff --git a/modules/sampling/noise_merge/tests/test_noise_merge.py b/modules/sampling/noise_merge/tests/test_noise_merge.py index c719382e..472ae8f4 100644 --- a/modules/sampling/noise_merge/tests/test_noise_merge.py +++ b/modules/sampling/noise_merge/tests/test_noise_merge.py @@ -255,3 +255,23 @@ def test_simultaneous_actions_are_skipped_when_the_conditioning_changed_or_the_s wrap(model, x0.clone(), _sched()[1:2], None, ctx, None, to, latent_shapes=shapes) assert all(torch.equal(c, ctx) for c in seen) # every call carries the prompt as it was + + +def test_the_new_noise_slider_decides_what_the_second_option_starts_from(tiny_h3): + x0, shapes = _packed() + ctx = torch.randn(1, 5, 48) + opts = [torch.randn(1, 5, 48), torch.randn(1, 5, 48)] + got = {} + for fresh in (0.0, 1.0): + wrap, _ = _load(tiny_h3, steps="2", strength=0.5, fresh=fresh, seed=5) + starts = [] + + def model(x, t, c_concat, c_crossattn, *a, **k): + starts.append(x.clone()) + return x0 + 0.1 * x + + wrap(model, x0.clone(), _sched()[1:2], None, ctx, None, _simul_to(1, opts), latent_shapes=shapes) + assert len(starts) == 2 and torch.equal(starts[0], x0) # the first option always gets the step's input + got[fresh] = starts[1] + assert torch.equal(got[0.0], x0) # 0: the same noise for both options + assert not torch.allclose(got[1.0], x0) and got[1.0].dtype == x0.dtype and got[1.0].device == x0.device