Files
Claude bd30a9524e Two stages: wrappers behind it wrap the whole run once; Noise merging, Seed search, Camera move, Shot memory follow the cut
Noise merging's per-run start ran once per stage, so it reported no matching step in the stage without the named step,
and the Second run restarted from zero at the cut. Two stages now moves the OUTER_SAMPLE wrappers behind it around the
staged run, and exposes the stage on the guider for the per-stage sampler wrappers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjXkZ7WjhgGdsdUwPsc
2026-10-29 12:52:25 +00:00

278 lines
12 KiB
Python

"""Shot memory: the starting noise remembers the shots you liked.
With the prompt fixed, what a like or dislike reacts to is mostly the SHOT -- where
things sit, how the frame is split. The model decides that from the coarse part of
the starting noise, so a rating lands on the thing that caused it instead of on an
averaged direction in a space thousands of dimensions wide.
Each run's coarse noise (one value per 4x4-latent cell per channel, averaged over
the whole clip) waits on the taste key for a rating. The next run then either
reuses a liked shot -- its coarse noise blended into the fresh noise, the fine
noise always fresh so the details are new -- or starts fresh. Both choices learn
from ratings (Thompson draws): whether reusing beats fresh for this key, which
liked shot, and how strongly (one number, nudged toward what was liked). A liked
child is a shot in its own right, so good shots breed.
The noise stays unit Gaussian: swapping one block mean for another of the same
variance changes nothing the model can tell apart from ordinary noise. Zero model
calls. Sound noise is never touched. A latent that is not empty (a second pass, an
anchor) is left alone, and the run says so.
Validated on a GPU. v4 lessons kept: reusing a shot's own seed stacks its layout
1.41x (the blend is capped per channel, only ever turned down), and a one-cell grid
has no spread (left alone).
"""
import math
import random
import torch
import torch.nn.functional as F
from comfy.patcher_extension import WrappersMP
from ..._core import dit_hooks, log, registry, streams
ID = "shot_memory"
TITLE = "Shot memory"
MOUNT = "generation.sampling"
STAGE = "latent"
CATEGORY = "sampling"
STATUS = "experimental"
REQUIRES = ["temporal_latent"]
USES = ["taste_store"]
SETTINGS = {
"enabled": {
"type": "bool", "default": False,
"label": "Shot memory",
"hint": "Some runs start from the layout of a shot you liked, with new details; ratings tune how often.",
},
"mode": {
"type": "enum", "default": "learned", "options": [
{"value": "learned", "label": "Learn from ratings"},
{"value": "manual", "label": "My value"},
],
"label": "How much", "ui": "segmented",
"hint": "Learn from ratings: each rating nudges how much is reused toward what you liked. My value: you set it below.",
"when": {"enabled": True},
},
"amount": {
"type": "float", "default": 0.7, "min": 0.0, "max": 0.95, "step": 0.05,
"label": "Reuse amount", "ui": "slider",
"hint": "How much of the liked layout carries over. 0 = none.",
"when": {"enabled": True, "mode": "manual"},
},
}
KIND = "shot_memory"
# Latent pixels per coarse cell: 64 image px on H3's 16x VAE.
# ponytail: fixed; learn it per key if one cell size stops fitting every resolution.
CELL = 4
MAX_ROWS = 64
START_AMOUNT = 0.7
EXPLORE = 0.15
LR = 0.5
AMOUNT_MIN, AMOUNT_MAX = 0.2, 0.95
def _say(message, level=log.ALERT):
log.once(f"{ID}:{message}", level, "FunUI Shot memory", message)
def _sign(x):
return (x > 0) - (x < 0)
def coarse_of(video):
"""[1, C, T, H, W] noise -> its coarse part [C, h, w], unit variance, or None."""
if video.dim() != 5 or video.shape[0] != 1:
return None
_b, c, t, H, W = video.shape
h, w = H // CELL, W // CELL
if h < 2 or w < 2: # a layout needs cells to compare; one cell has no spread
return None
blocks = video[0, :, :, :h * CELL, :w * CELL].float().reshape(c, t, h, CELL, w, CELL)
return blocks.mean(dim=(1, 3, 5)) * math.sqrt(t * CELL * CELL)
def with_coarse(video, coarse):
"""`video` with its coarse part replaced by `coarse` [C, h, w]; the fine part kept."""
_b, c, t, _H, _W = video.shape
h, w = coarse.shape[-2:]
core = video[0, :, :, :h * CELL, :w * CELL].float().reshape(c, t, h, CELL, w, CELL)
delta = coarse.to(core) / math.sqrt(t * CELL * CELL) - core.mean(dim=(1, 3, 5))
core = core + delta[:, None, :, None, :, None]
out = video.clone()
out[0, :, :, :h * CELL, :w * CELL] = core.reshape(c, t, h * CELL, w * CELL).to(video.dtype)
return out
def fit(coarse, h, w, device=None):
"""A stored coarse map on this run's grid and device (stored maps live on the CPU; the
noise is on the GPU). Another resolution is resampled and re-standardised: the layout
survives, exact Gaussianity does not."""
coarse = coarse.float().to(device) if device is not None else coarse.float()
if tuple(coarse.shape[-2:]) == (h, w):
return coarse
x = F.interpolate(coarse[None], size=(h, w), mode="bilinear", align_corners=False)[0]
return (x - x.mean()) / x.std().clamp(min=1e-6)
def pooled(cond):
if not torch.is_tensor(cond):
return None
p = cond.detach().float()
while p.dim() > 1:
p = p.mean(dim=0)
return p.cpu()
def shots(rows):
"""The key's rated shots, oldest first, as plain dicts."""
out = []
for r in rows:
p = r["rows"]
if "coarse" in p and "id" in p:
out.append({"id": int(p["id"]), "parent": int(p["parent"]), "amount": float(p["amount"]),
"reward": float(r["reward"]), "coarse": p["coarse"], "cond": p.get("cond")})
return out
def learned_amount(rows):
"""How strongly to reuse, replayed from the rated reuses in order: a liked one pulls
the value toward the amount it used, a disliked one pushes it away."""
m = START_AMOUNT
for r in rows:
if r["parent"] < 0:
continue
m += LR * _sign(r["reward"]) * (r["amount"] - m)
m = min(max(m, AMOUNT_MIN), AMOUNT_MAX)
return m
def choose_parent(rows, cond, channels, rng=random):
"""-> (liked shot to reuse | None, why). Two Thompson draws: reuse-vs-fresh from how
reuses and fresh shots have been rated, then which liked shot, weighted by how alike
its prompt was."""
by_id = {r["id"] for r in rows}
reuse_ab, fresh_ab, kids = [1.0, 1.0], [1.0, 1.0], {}
for r in rows:
s = _sign(r["reward"])
if not s:
continue
(fresh_ab if r["parent"] < 0 else reuse_ab)[0 if s > 0 else 1] += 1.0
if r["parent"] in by_id:
kids.setdefault(r["parent"], [1.0, 1.0])[0 if s > 0 else 1] += 1.0
liked = [r for r in rows if r["reward"] > 0 and int(r["coarse"].shape[0]) == int(channels)]
if not liked:
return None, "fresh: no liked shot yet"
reuse, fresh_ = rng.betavariate(*reuse_ab), rng.betavariate(*fresh_ab)
if fresh_ >= reuse:
return None, f"fresh (reuse {reuse:.2f} < fresh {fresh_:.2f})"
best, best_theta = None, -1.0
for r in liked:
a, b = kids.get(r["id"], [1.0, 1.0])
theta = rng.betavariate(a + 1.0, b) # +1: its own like
stored = r["cond"]
if cond is not None and torch.is_tensor(stored) and stored.numel() == cond.numel():
# ponytail: raw pooled-prompt cosine; sits high for most prompts under one key.
theta *= float(F.cosine_similarity(cond, stored.float(), dim=0).clamp(0.0, 1.0))
if theta > best_theta:
best, best_theta = r, theta
return best, f"reusing a liked shot of {len(liked)} (reuse {reuse:.2f} > fresh {fresh_:.2f})"
def blend(own, parent_coarse, amount):
"""The layout to start from: the liked shot's, `amount` of the way."""
coarse = amount * fit(parent_coarse, *own.shape[-2:], device=own.device) + math.sqrt(1.0 - amount ** 2) * own
# Unit variance only holds when the two layouts are unrelated. Rerunning the liked
# shot's own seed makes them the same pattern and the blend stacks it (1.41x at 0.7):
# back to a normal-strength layout, per channel. Only ever down: a few-cell grid's
# measured spread can read low by chance, and dividing by it would strengthen it.
return coarse / coarse.std(dim=(1, 2), keepdim=True).clamp(min=1.0)
def _prompt_of(guider):
try:
return pooled(guider.conds["positive"][0]["cross_attn"])
except (AttributeError, KeyError, IndexError, TypeError):
return None
def install(patcher, values, key):
if not values.get("enabled"):
return None
taste = registry.current().ask("taste_store", patcher)
if taste is None:
_say("off: set a Taste key first -- it is what your ratings teach")
return None
manual = values.get("mode") == "manual"
manual_amount = min(max(float(values.get("amount", START_AMOUNT)), 0.0), AMOUNT_MAX)
live = {"rows": [], "plan": None}
def fresh():
live["rows"], live["plan"] = shots(taste.rows(KIND, blind_to="image")), None
liked = sum(1 for r in live["rows"] if r["reward"] > 0)
log.once(f"{ID}:state", log.INFO, "FunUI Shot memory",
f"key {taste.key!r}: {liked} liked shot(s) of {len(live['rows'])} rated")
captured = taste.collect(patcher, key, KIND, keep=MAX_ROWS, fresh=fresh)
def plan(cond, channels):
if live["plan"] is None:
parent, why = choose_parent(live["rows"], cond, channels)
if parent is not None and manual and manual_amount <= 0.0:
parent, why = None, "fresh (reuse amount is 0)" # not a reuse: do not record one
amount = 0.0
if parent is not None:
amount = manual_amount if manual else min(max(
learned_amount(live["rows"]) + random.gauss(0.0, EXPLORE), AMOUNT_MIN), AMOUNT_MAX)
why += f", amount {amount:.2f}"
log.once(f"{ID}:plan", log.INFO, "FunUI Shot memory", why)
live["plan"] = (parent, amount)
return live["plan"]
def sampler_sample(executor, model_wrap, sigmas, extra_args, callback, noise,
latent_image=None, denoise_mask=None, disable_pbar=False):
def run(n):
return executor(model_wrap, sigmas, extra_args, callback, n,
latent_image, denoise_mask, disable_pbar)
if dit_hooks.stage_of(model_wrap) == 2:
return run(noise) # the layout was set in the first stage
named = {"latent_shapes": getattr(getattr(model_wrap, "inner_model", None), "latent_shapes", None)}
split = streams.video_of(noise, named)
if split is None:
_say("off this run: could not find the picture in this model's noise")
return run(noise)
video, rebuild = split
base = streams.video_of(latent_image, named) if latent_image is not None else None
if base is None or bool(torch.count_nonzero(base[0])):
_say("Inactive | the starting latent is not empty (a second pass, an anchor or a "
"start video), so the layout is already set and shot memory leaves this run's noise alone")
return run(noise)
own = coarse_of(video)
if own is None:
_say(f"Inactive | the picture is under {2 * CELL}x{2 * CELL} latent px (two layout "
"cells a side) or batched, so shot memory leaves it alone")
return run(noise)
cond = _prompt_of(model_wrap)
parent, amount = plan(cond, int(own.shape[0]))
coarse = own
if parent is not None:
coarse = blend(own, parent["coarse"], amount)
video = with_coarse(video, coarse)
captured.update({
"id": torch.tensor(random.getrandbits(62)),
"parent": torch.tensor(parent["id"] if parent else -1),
"amount": torch.tensor(float(amount)), "coarse": coarse.half().cpu()})
if cond is not None:
captured["cond"] = cond.half()
return run(rebuild(video))
patcher.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, key, sampler_sample)
return ("learned" if not manual else f"manual {manual_amount:g}") + ": starts from a liked layout when one is drawn"
PROVIDES = {"modifier": install}