"""Taste keys: named stores of what you liked and disliked, for features that learn. This module learns nothing itself. It names the key a run teaches, keeps each learning feature's capture of the latest run until you rate it, and answers "which way is liked?" per feature. Features reach it through the `taste_store` capability, so turning this module off turns every learning feature off with a message instead of breaking it. """ import asyncio import os import tempfile import re from . import store, value from ... import _core ID = "taste" TITLE = "Taste key" MOUNT = "generation.sampling" STAGE = "load" CATEGORY = "system" STATUS = "proven" SETTINGS = { "key": { "type": "text", "default": "", "label": "Taste key", "hint": "Name what your ratings teach, e.g. a style or a character. Empty = learning features stay off.", }, } _OPTION = "funui_taste_key" _judges = {} class Handle: """One key, as a learning feature sees it: capture now, ask for a direction later.""" def __init__(self, key): self.key = key def capture(self, kind, rows, keep=store.MAX_ROWS, only=None, mixed=False): store.capture(self.key, kind, rows, keep=keep, only=only, mixed=mixed) def rows(self, kind, blind_to=None): """Every rated row of `kind`, oldest first: {"reward", "rows", "prompt_id"}. `blind_to` ("image"/"composition"): dislikes blamed on that axis alone read as neutral -- for a learner that cannot tell whether that half was the problem.""" return store.blind(store.load(self.key, kind)["rows"], blind_to) def collect(self, patcher, key, kind, keep=store.MAX_ROWS, fresh=None, only=None, mixed=False): """A dict to fill during sampling; kept as `kind`'s capture when the sampling call finishes. An interrupted or failed run keeps nothing, so a half-finished clip can never be what a rating teaches. `fresh()` runs as each sampling starts. What was learned must be read THERE, not at install: ComfyUI caches the node that installs modifiers (only the seed changes between runs), so install-time reads would ignore every rating after the first run.""" from comfy.patcher_extension import WrappersMP rows = {} def outer(executor, *args, **kwargs): rows.clear() if fresh is not None: fresh() out = executor(*args, **kwargs) self.capture(kind, rows, keep=keep, only=only, mixed=mixed) return out patcher.add_wrapper_with_key(WrappersMP.OUTER_SAMPLE, key, outer) return rows def direction(self, kind, name): return store.direction(self.key, kind, name) def directions(self, kind, names, strength): """-> ({name: unit vector}, one-line summary) for the names that have enough ratings, with the ones still waiting named in the summary.""" found, steering, waiting = {}, [], [] for name in names: d, liked, disliked = self.direction(kind, name) if d is None: waiting.append(f"{name} ({liked}/{disliked})") else: found[name] = d steering.append(f"{name} ({liked}/{disliked})") parts = [] if steering: parts.append(f"steering {', '.join(steering)} at {strength:g}" if strength > 0 else f"strength 0: learning only at {', '.join(steering)}") if waiting: parts.append(f"learning, needs 2 liked + 2 disliked at {', '.join(waiting)}") return found, f"key {self.key!r}: " + "; ".join(parts or ["learning"]) def describe(self, video): return value.describe(video) def judge(self, kind, name): """A Judge trained on this key's `kind` rows holding `name`, or None while there are too few. Retrained only when the rows change.""" rows = [r for r in store.load(self.key, kind)["rows"] if str(name) in r["rows"]] slot = (self.key, kind, str(name)) stamp = tuple((r.get("prompt_id"), r["reward"]) for r in rows) hit = _judges.get(slot) if hit is None or hit[0] != stamp: hit = _judges[slot] = (stamp, value.Judge.train_on( [r["rows"][str(name)] for r in rows], [r["reward"] for r in rows])) return hit[1] def counts(self, kind, name=None): return store.counts(self.key, kind, name) def install(patcher, values, key): name = str(values.get("key") or "").strip() if not name: return None if not store.valid(name): raise ValueError(f"{name!r} can't be a key name: letters, digits, space, _ . - only") patcher.model_options[_OPTION] = name return f"teaching {name!r}" def taste_store(patcher): """The key this model's run teaches, or None when no key is set.""" name = (getattr(patcher, "model_options", None) or {}).get(_OPTION) return Handle(name) if name else None class Kind: """One kind of one key, for a panel that reads or clears it with no run going.""" def __init__(self, key, kind): if not store.valid(key): raise ValueError(f"{key!r} is not a usable taste key name") self.key, self.kind = key, kind def rows(self): return store.load(self.key, self.kind)["rows"] def path(self): return store.kind_path(self.key, self.kind) def clear(self): store.clear_kind(self.key, self.kind) def taste_latest_key(): return store.latest_key() def cache_key(): """Changes whenever a rating reaches the latest key. ComfyUI skips a graph whose inputs did not change, and a rating is not an input: the same seed rated and sent again would run nothing, for every module that learns from ratings. Waiting captures are left out: every run writes one.""" try: key = store.latest_key() folder = store._dir(key) if key else None if folder is None or not folder.is_dir(): return "" return "|".join(f"{p.name}:{p.stat().st_mtime_ns}:{p.stat().st_size}" for p in sorted(folder.glob("*.pt")) if ".pending." not in p.name) except Exception: # noqa: BLE001 -- a fingerprint must never fail a run return "" def taste_kind(key, kind): return Kind(key, kind) # What each kind of learning is for, for the Files panel. Kinds are written by modules; an unknown kind shows its name. KIND_INFO = { "reins": ("Taste steering (REINS)", "Per-block steering learned from liked and disliked clips"), "block_influence": ("Block influence", "Research: how much each block pushes the picture, for the four-group report"), "shot_memory": ("Shot memory", "Camera and shot choices that were liked or disliked"), "q_steer": ("Taste attention", "Attention steering learned from ratings"), "dynashift": ("DynaShift", "Steers the picture away from disliked results, late in the run"), "decisiveness": ("Decisiveness", "How firmly steps commit, learned from ratings"), "stas": ("Massive-activation steering", "Steering on the strongest channels, learned from ratings"), "x0_final": ("Taste guidance", "Output guidance learned from ratings"), "prompt_taste": ("Taste slider", "Prompt direction for the score slider"), "first_step": ("First-step seed search", "Which seeds start well, learned from ratings"), "late_branch": ("Late-branch guidance", "Guidance applied late in the run, learned from ratings"), "velocity": ("Velocity bias", "Motion bias learned from ratings"), "attention_bandit": ("Attention explorer", "Which attention changes per depth group were liked or disliked"), "x0_quarters": ("Early taste guidance", "Guidance from the first quarters of the run, learned from ratings"), } _KIND = re.compile(r"\A[a-z][a-z0-9_]{0,63}\Z") def key_kinds(key): """-> [{kind, title, hint, bytes}] for every kind a key holds (pending captures count toward their kind).""" folder = store._dir(key) if not folder.is_dir(): return [] found = {} for path in folder.glob("*.pt"): kind = path.name.split(".", 1)[0] if _KIND.match(kind): found[kind] = found.get(kind, 0) + path.stat().st_size out = [] for kind in sorted(found): title, hint = KIND_INFO.get(kind, (kind, "Learned from ratings")) out.append({"kind": kind, "title": title, "hint": hint, "bytes": found[kind]}) return out def routes(table, base, web): @table.get(base + "/keys") async def _keys(_req): return web.json_response({"keys": store.keys()}) @table.get(base + "/keys/{name}/kinds") async def _kinds(req): try: return web.json_response({"kinds": key_kinds(req.match_info["name"])}) except ValueError as exc: return web.json_response({"why": str(exc)}, status=400) @table.delete(base + "/keys/{name}/kinds/{kind}") async def _clear_kind(req): """Forget one kind of one key: its rated rows and waiting captures. The rest of the key stays.""" name, kind = req.match_info["name"], req.match_info["kind"] if not _KIND.match(kind): return web.json_response({"why": f"{kind!r} is not a kind name"}, status=400) try: store.clear_kind(name, kind) except ValueError as exc: return web.json_response({"why": str(exc)}, status=400) return web.json_response({"kinds": key_kinds(name)}) @table.post(base + "/rate") async def _rate(req): try: body = await req.json() out = store.rate(body.get("prompt_id"), body.get("rating"), body.get("axis")) except (ValueError, AttributeError) as exc: return web.json_response({"why": str(exc)}, status=400) # any module that learns from ratings (provides "on_rating") hears of it; one failing never loses the rating taught = [] for spec in _core.registry.current().specs.values(): hear = spec.provides.get("on_rating") if hear: try: if hear(body.get("prompt_id"), body.get("rating"), body.get("axis")): taught.append(spec.id) except Exception as exc: _core.log.once(f"on_rating:{spec.id}", _core.log.ALERT, spec.title, f"could not learn from this rating: {exc}") if taught: # something else learned from it (the camera keeps its own memory): the rating did teach, so the # "captured nothing" notice would be wrong out["taught"] = taught if not out.get("recorded") and not out.get("updated"): out.pop("why", None) out.pop("reason", None) return web.json_response(out) @table.post(base + "/generation") async def _generation(req): """A Generate is starting: captures still waiting for a rating are forgotten, except the run it just queued (`{prompt_id}`): a short run can capture before this request lands.""" body = await req.json() if req.can_read_body else {} keep = body.get("prompt_id") if isinstance(body, dict) and isinstance(body.get("prompt_id"), str) else None return web.json_response({"dropped": store.new_generation(keep=keep)}) @table.delete(base + "/keys/{name}") async def _delete(req): name = req.match_info["name"] try: removed = len(list(store._dir(name).glob("*"))) if store._dir(name).is_dir() else 0 store.delete(name) except ValueError as exc: return web.json_response({"why": str(exc)}, status=400) return web.json_response({"keys": store.keys(), "deleted": name, "removed": removed}) @table.get(base + "/keys/{name}/export") async def _export(req): """A key as a zip, to carry to another machine. Written to a temp file off the loop.""" name = req.match_info["name"] fd, tmp = tempfile.mkstemp(suffix=".zip") os.close(fd) try: await asyncio.to_thread(store.export_key, name, tmp) except ValueError as exc: os.remove(tmp) return web.json_response({"why": str(exc)}, status=400) try: with open(tmp, "rb") as fh: body = fh.read() finally: os.remove(tmp) return web.Response(body=body, content_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{name}.zip"'}) @table.post(base + "/keys/import") async def _import(req): """Take an exported key (the zip, as the raw request body) under `?name=`. Streamed to a temp file: a key can be tens of MB, past what reading a body whole allows.""" name = req.query.get("name", "") if not store.valid(name): return web.json_response({"why": f"{name!r} is not a usable taste key name"}, status=400) fd, tmp = tempfile.mkstemp(suffix=".zip") size = 0 try: with os.fdopen(fd, "wb") as out: async for chunk in req.content.iter_chunked(1 << 20): size += len(chunk) if size > store.MAX_BYTES: return web.json_response({"why": "that file is larger than 4 GB"}, status=413) out.write(chunk) try: count = await asyncio.to_thread(store.import_key, name, tmp, req.query.get("overwrite") == "1") except FileExistsError: return web.json_response({"why": f'A key named "{name}" already exists.', "exists": True, "key": name}, status=409) except ValueError as exc: return web.json_response({"why": str(exc)}, status=400) finally: if os.path.exists(tmp): os.remove(tmp) return web.json_response({"imported": name, "kinds": count, "keys": store.keys()}) PROVIDES = {"modifier": install, "taste_store": taste_store, "taste_kind": taste_kind, "taste_latest_key": taste_latest_key, "cache_key": cache_key, "routes": routes}