Files
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
2026-10-25 13:11:03 +00:00

472 lines
20 KiB
Python

"""Named taste keys on disk, and pairing a run's capture with its rating.
Layout: `<pack>/taste/<key>/<kind>.pt` holds `{"rows": [{"prompt_id", "reward",
"rows": {name: tensor}}]}`; `<kind>.pending.pt` holds the latest run's capture and
`<kind>.<prompt id>.pending.pt` the earlier runs' of the SAME Generate.
A key is a folder, so deleting one is one folder and a kind is one file.
A capture waits for a rating until the next Generate starts (`new_generation`), then is forgotten:
nothing is learned from a clip nobody rated. A Generate can be several runs (one per scene that
starts its own run), so every run of the latest Generate waits, each TAGGED with the ComfyUI prompt
id it came from -- rating a clip whose capture was dropped is refused and said, never paired with
the wrong run. Changing your mind on a clip already recorded updates its row instead of adding a
second one.
Keys are disposable (retrained per rental), so there is no migration from v4's
`refinements/` files.
"""
import json
import os
import re
import shutil
import tempfile
import threading
import zipfile
import zlib
from pathlib import Path
import torch
from ..._core import config
ROOT = config.ROOT / "taste"
_KEY = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}\Z")
_LOCK = threading.RLock()
REWARD = {"liked": 1.0, "disliked": -1.0}
# Each rating's vote shrinks by this per later rating: half the vote sits in the
# last ~7 (v4's RECENCY_DECAY; without it early ratings of one style outvoted a
# new style until out-rated one-for-one).
RECENCY_DECAY = 0.9
# Two liked AND two disliked before a direction exists: one of each is two
# points, not a direction.
MIN_PER_GROUP = 2
# A dislike can say WHICH half went wrong: "image" (the picture was bad) or "composition"
# (the picture was fine, the shot plan was not). Plain disliked blames both.
AXES = ("image", "composition")
MAX_ROWS = 200
def valid(key) -> bool:
return isinstance(key, str) and bool(_KEY.match(key)) and not key.endswith((".", " ")) \
and not key.lower().endswith(".json") # `latest.json` lives beside the keys: a key cannot share its name
def _dir(key):
if not valid(key):
raise ValueError(f"{key!r} is not a usable taste key name")
return ROOT / key
def keys():
if not ROOT.is_dir():
return []
return sorted(p.name for p in ROOT.iterdir() if p.is_dir() and valid(p.name))
def _read(path, default):
try:
return torch.load(path, map_location="cpu", weights_only=True)
except FileNotFoundError:
return default
def _write(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
torch.save(data, tmp)
os.replace(tmp, path)
_loaded = {}
def load(key, kind):
"""Rated rows of a kind. Re-read only when the file changed: features ask at
the start of every run, and a REINS log is tens of MB."""
path = _dir(key) / f"{kind}.pt"
try:
stamp = path.stat().st_mtime_ns
except FileNotFoundError:
return {"rows": []}
hit = _loaded.get(path)
if hit is None or hit[0] != stamp:
hit = _loaded[path] = (stamp, _read(path, {"rows": []}))
return hit[1]
def latest_key():
"""The key the most recent capture went to, or None."""
return _read_latest().get("key")
def kind_path(key, kind):
"""Where a kind's rated rows live on disk (it may not exist yet)."""
return _dir(key) / f"{kind}.pt"
def clear_kind(key, kind):
"""Forget every rated row and the waiting captures of one kind."""
kind_path(key, kind).unlink(missing_ok=True)
for path in [_pending_path(key, kind), *_dir(key).glob(f"{kind}.*.pending.pt")]:
path.unlink(missing_ok=True)
def current_prompt_id():
"""The ComfyUI prompt running now -- set before any node executes."""
try:
from server import PromptServer
return getattr(PromptServer.instance, "last_prompt_id", None)
except Exception: # noqa: BLE001 -- tests, headless
return None
def _latest_path():
return ROOT / "latest.json"
MAX_PENDING = 64 # earlier runs of one Generate kept waiting, per kind
_PID = re.compile(r"\A[A-Za-z0-9_-]{1,80}\Z") # a prompt id names a file: only plain ones may
def _pending_path(key, kind, prompt_id=None):
"""The latest run's waiting capture (no id), or an earlier run's of the same Generate."""
return _dir(key) / (f"{kind}.pending.pt" if prompt_id is None else f"{kind}.{prompt_id}.pending.pt")
def _prune_aside(key, kind):
aside = sorted(_dir(key).glob(f"{kind}.*.pending.pt"), key=lambda p: p.stat().st_mtime_ns)
for old in aside[:-MAX_PENDING]:
old.unlink(missing_ok=True)
def capture(key, kind, rows, prompt_id=None, keep=MAX_ROWS, only=None, mixed=False):
"""This run's capture for `kind`, waiting for a rating until the next Generate starts.
The previous run's capture of the same Generate is kept aside, not overwritten. Kept in the
dtype given: a banked latent stays half precision. `keep` caps how many rated rows this kind
holds, oldest dropped first. `only="liked"`: a kind that learns from liked clips alone does not
bank a disliked one (it could only push the liked ones out under `keep`). `mixed`: its rows are
legitimately of different shapes (a clip bank is matched by size), so import does not demand one.
"""
if not rows:
return
prompt_id = prompt_id or current_prompt_id()
clean = {str(k): v.detach().cpu() for k, v in rows.items()}
with _LOCK:
latest = _pending_path(key, kind)
before = _read(latest, None)
earlier = before.get("prompt_id") if before else None
if earlier is not None and earlier != prompt_id:
if _PID.match(str(earlier)):
os.replace(latest, _pending_path(key, kind, earlier))
_write(latest, {"prompt_id": prompt_id, "rows": clean, "keep": int(keep), "only": only, "mixed": bool(mixed)})
_prune_aside(key, kind)
state = _read_latest()
runs = state.get("runs", {})
kinds = runs.setdefault(prompt_id, {}).setdefault(key, [])
if kind not in kinds:
kinds.append(kind)
ROOT.mkdir(parents=True, exist_ok=True)
_latest_path().write_text(json.dumps({"key": key, "prompt_id": prompt_id, "runs": runs, "forgot": state.get("forgot", [])}))
def _held_by(path, prompt_id):
"""Whether a waiting capture is `prompt_id`'s. An earlier run's carries its id in the name; only the latest
(`<kind>.pending.pt`) is opened, and one that cannot be read is nobody's (it must not stop the rest going)."""
if path.name.count(".") > 2:
return path.name.split(".")[1] == prompt_id
try:
return (_read(path, None) or {}).get("prompt_id") == prompt_id
except Exception: # noqa: BLE001
return False
def new_generation(keep=None):
"""A Generate is starting: every capture still waiting for a rating is forgotten, but `keep`'s (the run this
Generate already queued, which may have captured before this call). -> how many were dropped."""
with _LOCK:
dropped = 0
for key in keys():
for path in _dir(key).glob("*.pending.pt"):
if keep is not None and _held_by(path, keep):
continue
path.unlink(missing_ok=True)
dropped += 1
state = _read_latest()
if state:
runs = state.get("runs", {})
# remembered so a late rating can say this was why, and only then
forgot = [*state.get("forgot", []), *(p for p in runs if p != keep)][-256:]
_latest_path().write_text(json.dumps({"key": state.get("key"), "runs": {p: v for p, v in runs.items() if p == keep}, "forgot": forgot}))
return dropped
def _read_latest():
try:
return json.loads(_latest_path().read_text())
except (FileNotFoundError, ValueError):
return {}
def blind(rows, to):
"""`rows` as a learner that cannot judge the `to` axis sees them: a dislike blamed on
that axis alone is neutral (reward 0, so it counts as neither liked nor disliked)."""
if not to:
return rows
return [dict(r, reward=0.0) if r.get("axis") == to else r for r in rows]
def _restore_pending(key, kind, prompt_id, rows, keep, only=None, mixed=False):
"""A cleared rating puts the capture back to waiting (until the next Generate), so the clip can be
rated again. The latest slot if it is free or already this run's, else aside under the prompt id."""
latest = _pending_path(key, kind)
held = _read(latest, None)
if held and held.get("prompt_id") not in (None, prompt_id):
if not _PID.match(str(prompt_id)):
return
latest = _pending_path(key, kind, prompt_id)
_write(latest, {"prompt_id": prompt_id, "rows": rows, "keep": int(keep), "only": only, "mixed": bool(mixed)})
_prune_aside(key, kind)
def rate(prompt_id, rating, axis=None):
"""-> {"recorded": [kinds], "updated": [kinds], "why": str|None}.
`rating` None clears: the row is removed, nothing is learned from that clip -- and while its
Generate is still the latest, the capture waits again so it can be rated again.
`axis` only goes with "disliked" (see AXES); any other rating drops it.
"""
if not isinstance(prompt_id, str) or not prompt_id:
raise ValueError("a rating names the clip it is about (no prompt id was given)")
if rating is not None and rating not in REWARD:
raise ValueError(f"{rating!r} is not a rating")
if axis is not None and axis not in AXES:
raise ValueError(f"{axis!r} is not a rating axis")
if axis is not None and rating != "disliked":
raise ValueError("only a dislike can name what went wrong")
out = {"recorded": [], "updated": [], "why": None}
with _LOCK:
state = _read_latest()
entry = state.get("runs", {}).get(prompt_id) or {}
# A clip already recorded: change or remove its row, in every key/kind.
for key in keys():
for path in _dir(key).glob("*.pt"):
if path.name.endswith(".pending.pt"):
continue
data = _read(path, {"rows": []})
hit = [r for r in data["rows"] if r.get("prompt_id") == prompt_id]
if not hit:
continue
if rating is None or (hit[-1].get("only") == "liked" and rating != "liked"):
# a liked-only bank holds no dislike: it leaves, and may come back if liked again
data["rows"] = [r for r in data["rows"] if r.get("prompt_id") != prompt_id]
if path.stem in entry.get(key, []):
_restore_pending(key, path.stem, prompt_id, hit[-1]["rows"], hit[-1].get("keep", MAX_ROWS),
hit[-1].get("only"), hit[-1].get("mixed", False))
else:
for r in hit:
r["reward"] = REWARD[rating]
r.pop("axis", None)
if axis:
r["axis"] = axis
_write(path, data)
out["updated"].append(path.stem)
if out["updated"]:
return out
if not entry:
out["reason"] = "forgotten" if prompt_id in state.get("forgot", []) else "nothing" # a label the app can act on; `why` is for people
out["why"] = ("a new Generate started before this clip was rated, and unrated clips are forgotten then"
if out["reason"] == "forgotten" else
"this run captured nothing: no learning feature acted on it, or ComfyUI reused an "
"earlier result instead of sampling again")
return out
if rating is None:
return out
for key, kinds in entry.items():
for kind in kinds:
where = [_pending_path(key, kind)]
if _PID.match(str(prompt_id)):
where.append(_pending_path(key, kind, prompt_id))
for pending_path in where:
pending = _read(pending_path, None)
if pending and pending.get("prompt_id") == prompt_id:
break
else:
continue
data = load(key, kind)
keep = int(pending.get("keep", MAX_ROWS))
if pending.get("only") == "liked" and rating != "liked":
# nothing here learns from it, but a changed mind may still like it
out.setdefault("skipped", []).append(kind)
continue
row = {"prompt_id": prompt_id, "reward": REWARD[rating], "rows": pending["rows"], "keep": keep,
"only": pending.get("only"), "mixed": bool(pending.get("mixed"))}
if axis:
row["axis"] = axis
data["rows"].append(row)
data["rows"] = data["rows"][-keep:]
_write(_dir(key) / f"{kind}.pt", data)
pending_path.unlink(missing_ok=True)
out["recorded"].append(kind)
if not out["recorded"] and not out.get("skipped"):
out["why"] = ("this clip's capture was not kept (more runs waited than are kept, or it was "
"cleared); nothing was learned")
return out
def counts(key, kind, name=None):
"""(liked, disliked) rows for a kind, optionally only rows holding `name`."""
rows = [r for r in load(key, kind)["rows"] if name is None or str(name) in r["rows"]]
return (sum(r["reward"] > 0 for r in rows), sum(r["reward"] < 0 for r in rows))
def direction(key, kind, name):
"""-> (unit vector | None, n_liked, n_disliked).
Recency-weighted mean(liked) - mean(disliked) over the rows holding `name`,
split by the SIGN of the rating only. Raw rows, not normalised: a hidden
state's magnitude carries content (v4's trajectory-probe lesson).
"""
name = str(name)
rows = [r for r in load(key, kind)["rows"] if name in r["rows"]]
aged = [(r["rows"][name], r["reward"], RECENCY_DECAY ** (len(rows) - 1 - i))
for i, r in enumerate(rows)]
liked = [(d, a) for d, w, a in aged if w > 0]
disliked = [(d, a) for d, w, a in aged if w < 0]
if len(liked) < MIN_PER_GROUP or len(disliked) < MIN_PER_GROUP:
return None, len(liked), len(disliked)
def wmean(group):
ds = torch.stack([d for d, _ in group]).float()
a = torch.tensor([a for _, a in group], dtype=ds.dtype)
return (ds * a.view(-1, *([1] * (ds.dim() - 1)))).sum(0) / a.sum()
diff = wmean(liked) - wmean(disliked)
norm = diff.norm()
if not torch.isfinite(norm) or norm <= 1e-8:
return None, len(liked), len(disliked)
return diff / norm, len(liked), len(disliked)
def _forget_key_runs(key):
"""The key's waiting runs died with its folder; other keys' runs stay ratable."""
state = _read_latest()
if not state:
return
runs = {}
for pid, per_key in state.get("runs", {}).items():
left = {k: v for k, v in per_key.items() if k != key}
if left:
runs[pid] = left
state["runs"] = runs
if state.get("key") == key:
state["key"] = None
_latest_path().write_text(json.dumps(state))
def delete(key):
with _LOCK:
shutil.rmtree(_dir(key), ignore_errors=True)
_forget_key_runs(key)
# --- moving a key between machines -------------------------------------------
#
# A key is a folder of `<kind>.pt` files, so exporting is a zip of them. Importing reads a zip a
# stranger may have made: only plainly named `.pt` files, bounded in count and size, each one
# loadable with weights_only (no pickled code) before anything touches the real folder.
_KIND_FILE = re.compile(r"\A[A-Za-z0-9_]{1,48}\.pt\Z")
MAX_FILES = 64
MAX_BYTES = 4 * 1024 ** 3
def export_key(key, out_path):
"""Zip `key`'s rated rows into `out_path` (waiting captures are not part of what was learned)."""
folder = _dir(key)
if not folder.is_dir():
raise ValueError(f"there is no taste key called {key!r}")
with _LOCK:
found = [p for p in sorted(folder.glob("*.pt")) if _KIND_FILE.match(p.name)]
if not found:
raise ValueError(f"{key!r} has not learned anything yet: there is nothing to export")
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_STORED) as zf:
for path in found:
zf.write(path, path.name)
return out_path
def _sound_rows(data):
"""True when `data` is what the learners consume: a dict of rated rows, each with a number
for a reward and tensors for what was captured. Loading it safely is not enough -- a row of
the wrong shape would break every later rating."""
if not isinstance(data, dict) or not isinstance(data.get("rows"), list):
return False
shapes = {}
for row in data["rows"]:
if not isinstance(row, dict) or isinstance(row.get("reward"), bool) or not isinstance(row.get("reward"), (int, float)):
return False
if not isinstance(row.get("prompt_id"), str) or not row["prompt_id"]:
return False
if row.get("axis") not in (None, *AXES):
return False
captured = row.get("rows")
if not isinstance(captured, dict) or not all(isinstance(v, torch.Tensor) for v in captured.values()):
return False
# What is stacked later must agree: one name, one shape (a key trained across two model
# families would otherwise abort every sampling that reads it).
if row.get("mixed") is True:
continue # a clip bank: rows of different sizes are the point
for name, v in captured.items():
if shapes.setdefault(name, tuple(v.shape)) != tuple(v.shape):
return False
return True
def import_key(key, zip_path, overwrite=False):
"""-> number of kinds imported. FileExistsError if `key` exists and `overwrite` is off."""
dest = _dir(key)
try:
zf = zipfile.ZipFile(zip_path)
except zipfile.BadZipFile as exc:
raise ValueError("that is not an exported taste key (not a zip file)") from exc
with zf:
infos = zf.infolist()
if not infos or len(infos) > MAX_FILES:
raise ValueError(f"an exported key holds 1 to {MAX_FILES} files, this has {len(infos)}")
for info in infos:
if not _KIND_FILE.match(info.filename):
raise ValueError(f"{info.filename!r} is not part of a taste key")
if sum(i.file_size for i in infos) > MAX_BYTES:
raise ValueError("that key is larger than 4 GB")
ROOT.mkdir(parents=True, exist_ok=True)
stage = Path(tempfile.mkdtemp(prefix=".import-", dir=ROOT))
try:
for info in infos:
target = stage / info.filename
try:
with zf.open(info) as src, open(target, "wb") as out:
shutil.copyfileobj(src, out)
except (zipfile.BadZipFile, zlib.error, RuntimeError, NotImplementedError, EOFError) as exc:
raise ValueError(f"{info.filename} cannot be read from the zip ({type(exc).__name__}): "
f"it is damaged, encrypted or compressed in an unsupported way") from exc
try:
data = torch.load(target, map_location="cpu", weights_only=True)
except Exception as exc: # noqa: BLE001
raise ValueError(f"{info.filename} is not a readable taste file ({type(exc).__name__})") from exc
if not _sound_rows(data):
raise ValueError(f"{info.filename} does not hold rated rows of the shape FunUI reads")
with _LOCK:
if dest.exists():
if not overwrite:
raise FileExistsError(key)
shutil.rmtree(dest)
os.replace(stage, dest)
# Anything waiting to be rated under this name died with the old folder.
_forget_key_runs(key)
return len(infos)
finally:
shutil.rmtree(stage, ignore_errors=True)