From a01ffbd154fc84654d039af4d4180f445ada4bbc Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:18:30 +0300 Subject: [PATCH] feat: image-vs-composition dislikes and effect-weighted ratings for the rated dials Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++ conditioning.py | 19 +++++++++- movie_editor/frontend/rating_picker.js | 4 +- rated_dial.py | 29 +++++++++++++-- samplers.py | 20 +++++++++- tests/test_rating_axes.py | 51 ++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 tests/test_rating_axes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ac407328..d9cfa0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added +- H3 rating picker: "Disliked — bad image" and "Disliked — bad composition". Everything else reads them as Disliked; shot memory, decisiveness, late-branch and STAS learn only from composition blame, and DynaShift discards the latent when composition was the fault. +- Late-branch guidance now measures how hard it actually pushed; a rating counts up to 2x (down to 0.25x) when that run was unusually strong (weak) for the key, after 3 runs of history. + ### Added - **Camera move** (experimental, H3, Engine ▸ Guidance): a pan (any direction) or zoom (in/out, diff --git a/conditioning.py b/conditioning.py index 02ff42d1..687e2c3c 100644 --- a/conditioning.py +++ b/conditioning.py @@ -3561,6 +3561,8 @@ MOVIE_EDITOR_FRESH_PROMPT_RATING = "__funpack_fresh_prompt__" _BASE_RATING_LABELS = [ "-Just forget it-", + "Disliked: bad image", + "Disliked: bad composition", "Perfect", "Nailed it", "Missing details", @@ -3577,7 +3579,8 @@ _BASE_RATING_LABELS = [ "Awful", ] _NO_LOVED_LABELS = { - "-Just forget it-", "Perfect", "Awful", "Missing quality", + "-Just forget it-", "Perfect", "Awful", "Missing quality", "Disliked: bad image", + "Disliked: bad composition", "Missing details + quality", "Missing action + quality", "Wrong action + quality", MOVIE_EDITOR_CONTINUE_RATING, MOVIE_EDITOR_FRESH_PROMPT_RATING, @@ -3633,6 +3636,12 @@ V2_RATING_PROFILES = { "Missing details + quality": {"key": "missing_details_quality", "reward": -0.40, "level": 2, "missing_axes": ["details", "quality"]}, "Missing action + quality": {"key": "missing_action_quality", "reward": -0.55, "level": 1, "missing_axes": ["action", "quality"]}, "Awful": {"key": "awful", "reward": -0.90, "level": 0, "missing_axes": ["details", "action", "quality"]}, + # H3 follow-ups to Disliked: same key/reward as Awful, so every other consumer treats them + # as Awful; `axis` only tells the rated-dial learners which half was at fault. + "Disliked: bad image": {"key": "awful", "reward": -0.90, "level": 0, "axis": "image", + "missing_axes": ["details", "action", "quality"]}, + "Disliked: bad composition": {"key": "awful", "reward": -0.90, "level": 0, "axis": "composition", + "missing_axes": ["details", "action", "quality"]}, } V2_FEEDBACK_AXES = ("details", "action", "quality") @@ -10482,7 +10491,9 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): except ImportError: from negative_memory import consume_pending, is_negative_profile, \ is_positive_profile - if is_negative_profile(learning_profile): + if learning_profile.get("axis") == "composition": + consume_pending(refinement_key, False) # picture was fine: not a bad latent + elif is_negative_profile(learning_profile): n = consume_pending(refinement_key, True, rating_key=learning_profile.get("key"), bank="negative") @@ -10505,6 +10516,10 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): if has_previous_run and refinement_key and not learning_profile.get("skip_learning"): _sm_reward = (0.0 if learning_profile.get("skip_value_function") else float(learning_profile.get("reward", 0.0) or 0.0)) + # A dislike blamed on the picture alone says nothing about the shot plan + # these four learners choose (composition); neutral for them. + if learning_profile.get("axis") == "image": + _sm_reward = 0.0 try: try: from . import shot_memory as _sm, tsr as _tsr_mod, late_guidance as _lg, stas as _st diff --git a/movie_editor/frontend/rating_picker.js b/movie_editor/frontend/rating_picker.js index 679a6a99..9ab112f0 100644 --- a/movie_editor/frontend/rating_picker.js +++ b/movie_editor/frontend/rating_picker.js @@ -57,6 +57,8 @@ const H3_SCALE = [ { label: "10", display: "Liked", reward: "+", hint: "This generation was good." }, { label: "1", display: "Disliked", reward: "−", hint: "This generation was not." }, + { label: "Disliked: bad image", display: "Disliked — bad image", reward: "−", hint: "Composition was fine; the picture itself was ruined." }, + { label: "Disliked: bad composition", display: "Disliked — bad composition", reward: "−", hint: "Well drawn, but the shots/layout/movement were wrong." }, ]; // canonical label -> display name, for rendering a stored value (scene button, chips). @@ -66,7 +68,7 @@ const displayName = (label) => DISPLAY_NAMES[label] || label; const NO_LOVED = new Set([ - "Perfect", "Nailed it", "Awful", FORGET_LABEL, + "Perfect", "Nailed it", "Awful", FORGET_LABEL, "Disliked: bad image", "Disliked: bad composition", "Missing quality", "Missing details + quality", "Missing action + quality", "Wrong action + quality", // H3 scale (1-10): a bare number never reaches the label branch that applies the loved // boost (normalize_refiner_v2_rating), so the heart would silently do nothing. 10 already diff --git a/rated_dial.py b/rated_dial.py index e9ffdbc9..763ebf31 100644 --- a/rated_dial.py +++ b/rated_dial.py @@ -14,6 +14,18 @@ import random MODES = ("off", "learned", "manual") +MIN_EFFECTS = 3 # runs of history before an effect is compared with the key's own + + +def unusualness(effect, past, lo=0.25, hi=2.0): + """Weight of one rating: this run's effect over the key's median past effect, clamped. + 1.0 until enough runs exist to know what usual is.""" + if not effect or len(past) < MIN_EFFECTS: + return 1.0 + med = sorted(past)[len(past) // 2] + return min(max(effect / med, lo), hi) if med > 0 else 1.0 + + class Dial: def __init__(self, name, start, lo, hi, explore, lr=0.5): self.name, self.start, self.lo, self.hi = name, start, lo, hi @@ -47,7 +59,7 @@ class Dial: m = self.start for h in history: s = (h["reward"] > 0) - (h["reward"] < 0) - m += self.lr * s * (float(h["v"]) - m) + m += self.lr * s * h.get("w", 1.0) * (float(h["v"]) - m) m = min(max(m, self.lo), self.hi) return m @@ -62,10 +74,12 @@ class Dial: v = min(max(centre + rng.gauss(0.0, self.explore), self.lo), self.hi) return v, f"learned {centre:.2f} from {len(history)} rating(s), trying {v:.2f}" - def save_pending(self, refinement_key, v): + def save_pending(self, refinement_key, v, effect=None): + """`effect`: how hard the run's steering actually hit (None = not measured).""" if refinement_key: data = self.read(refinement_key) data["pending"] = float(v) + data["effect"] = float(effect) if effect else None self._write(refinement_key, data) def clear_pending(self, refinement_key): @@ -73,18 +87,25 @@ class Dial: if refinement_key and "pending" in self.read(refinement_key): data = self.read(refinement_key) data.pop("pending", None) + data.pop("effect", None) self._write(refinement_key, data) def commit(self, refinement_key, reward): - """Pair the pending value with its run's rating. -> ratings held, or None.""" + """Pair the pending value with its run's rating. -> ratings held, or None. + The rating counts more when the run's measured effect was unusually strong for this + key (and less when unusually weak): a verdict on a run that barely felt the feature + says little about it.""" if not refinement_key: return None data = self.read(refinement_key) v = data.pop("pending", None) + effect = data.pop("effect", None) if v is None: return None + weight = unusualness(effect, [h["e"] for h in data.get("history") or [] if h.get("e")]) if reward: data["history"] = ((data.get("history") or []) - + [{"v": float(v), "reward": float(reward)}])[-200:] + + [{"v": float(v), "reward": float(reward), + "w": weight, "e": effect}])[-200:] self._write(refinement_key, data) return len(data["history"]) if reward else None diff --git a/samplers.py b/samplers.py index 00043e33..db3e1966 100644 --- a/samplers.py +++ b/samplers.py @@ -904,6 +904,11 @@ class _InputSteer: # "cur": (step, push)}. A push made during step i is only usable from step i+1 on, # so a second call inside step i (a split batch, a hook group) never gets it. self._held = {} + self._hit, self._hits = 0.0, 0 # edit size / answer size, summed over steered calls + + def effect(self): + """Mean relative size of the edits made, or None when none were.""" + return self._hit / self._hits if self._hits else None def _where(self, args): c = args.get("c") if isinstance(args.get("c"), dict) else {} @@ -971,6 +976,11 @@ class _InputSteer: def keep(self, args, denoised, steered): """Hold `steered - denoised` for the next step and return the model's own answer.""" + try: + self._hit += float((steered - denoised).norm() / denoised.norm().clamp(min=1e-8)) + self._hits += 1 + except Exception: # noqa: BLE001 # a measurement must never break sampling + pass if not self.enabled: return steered where = self._where(args) @@ -4363,6 +4373,8 @@ class FunPackLTXAVSceneChainSampler: print(f"[FunPackSceneChain] STAS: {'Active' if _n else 'Inactive'} | {_line}") if _late_stats is not None: self._late_ran = getattr(self, "_late_ran", 0) + _late_stats["guided"] + _st_steer = _late_stats.get("steer") + self._late_effect = _st_steer.effect() if _st_steer is not None else None print("[FunPackSceneChain] late-branch guidance: " + ( f"Active | guided {_late_stats['guided']} step call(s)" if _late_stats["guided"] else "Inactive | " + (_late_stats["why"] or "no step call reached it, nothing guided"))) @@ -8891,7 +8903,7 @@ class FunPackLTXAVSceneChainSampler: old_wrapper = patched.model_options.get("model_function_wrapper") _w = float(w) - stats = {"guided": 0, "why": None} + stats = {"guided": 0, "why": None, "steer": None} # The wrapper lives in patched.model_options, so it must not hold `patched`: that # cycle is only freed by a full gc, which clears ComfyUI's weak link to the # parent copy first and leaves it tracking a dead model ("memory leak with model"). @@ -8900,6 +8912,7 @@ class FunPackLTXAVSceneChainSampler: # On H3 the push rides into the next step's input; the last step, whose answer # is the output, runs no weak copy at all. steer = _InputSteer(getattr(self, "_is_h3", False), "late-branch guidance") + stats["steer"] = steer def _guided(apply_fn, cond_only, args, final): def _apply(x, t, **c): @@ -9878,6 +9891,7 @@ class FunPackLTXAVSceneChainSampler: self._late_w, self._late_block = 0.0, int(late_guidance_block) self._stas_alpha, self._stas_block = None, int(stas_block) self._late_ran, self._stas_ran = 0, 0 # calls the features actually changed + self._late_effect = None # ponytail: last scene chunk's effect stands for the run self._tsr_acts = True # False: too weak on this schedule to be rated self._camera_move = None if camera_move != "off": @@ -11211,7 +11225,9 @@ class FunPackLTXAVSceneChainSampler: "late-branch guidance"), (stas == "learned", self._stas_ran, _stas.DIAL, self._stas_alpha, "STAS")): if _on and _ran and _v is not None: - _dial.save_pending(refinement_key_input, _v) + _dial.save_pending(refinement_key_input, _v, + getattr(self, "_late_effect", None) if _dial is _late.DIAL + else None) continue _dial.clear_pending(refinement_key_input) if _on and self._is_h3: diff --git a/tests/test_rating_axes.py b/tests/test_rating_axes.py new file mode 100644 index 00000000..9474a3fe --- /dev/null +++ b/tests/test_rating_axes.py @@ -0,0 +1,51 @@ +"""Two-axis dislikes route to the learners that own that axis; a rating counts more when the +run's measured effect was unusually strong for the key.""" +import sys +from pathlib import Path + +import torch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import conditioning # noqa: E402 +import rated_dial # noqa: E402 +import samplers # noqa: E402 + + +def test_axis_dislikes_are_awful_to_everyone_else_and_are_valid_choices(): + for label, axis in (("Disliked: bad image", "image"), ("Disliked: bad composition", "composition")): + p = conditioning.normalize_refiner_v2_rating(label) + assert p["key"] == "awful" and p["reward"] == -0.90 and p["axis"] == axis + assert label in conditioning.V2_RATING_LABELS # the graph builder keeps it + assert label + "|loved" not in conditioning.V2_RATING_LABELS + assert "axis" not in conditioning.normalize_refiner_v2_rating("Awful") + + +def test_unusualness_needs_history_and_is_clamped(): + assert rated_dial.unusualness(0.5, [0.1, 0.1]) == 1.0 # too little history + assert rated_dial.unusualness(None, [0.1] * 5) == 1.0 # not measured + assert rated_dial.unusualness(0.4, [0.1] * 5) == 2.0 # clamped high + assert rated_dial.unusualness(0.01, [0.1] * 5) == 0.25 # clamped low + assert abs(rated_dial.unusualness(0.15, [0.1] * 5) - 1.5) < 1e-9 + + +def test_dial_weights_a_strong_run_more(tmp_path, monkeypatch): + d = rated_dial.Dial("t", 0.5, 0.0, 1.0, 0.1) + store = {} + monkeypatch.setattr(d, "read", lambda k: dict(store.get(k, {}))) + monkeypatch.setattr(d, "_write", lambda k, data: store.__setitem__(k, data)) + for _ in range(3): + d.save_pending("k", 0.8, 0.1) + d.commit("k", 1.0) + d.save_pending("k", 0.8, 0.2) + d.commit("k", 1.0) + assert store["k"]["history"][-1]["w"] == 2.0 + assert store["k"]["history"][0]["w"] == 1.0 + + +def test_input_steer_records_how_hard_it_pushed(): + s = samplers._InputSteer(False, "x") + assert s.effect() is None + d = torch.ones(1, 2, 2, 2) + s.keep({}, d, d * 1.5) + assert abs(s.effect() - 0.5) < 1e-6