diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f11c917..d8d613c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Added +**Interactive Guessing** — a new Batch Training mode. Instead of varying the seed, it freezes *everything* (including the noise seed) and sweeps the **conditioning's spread** (its "sigma") on video channels along a **linear ramp** across the N rungs — up to amplify toward overbake, down to dampen — so you can see exactly where it breaks. Each rung records its factor. When you rate the ladder, it learns your **safe steering ceiling** for that key and **auto-caps future steering**: a soft pre-limit on absolute/relative steer strength plus a hard clamp on the output conditioning's spread (the guarantee). Audio is never touched (reuses the LTXAV video/audio channel split). The Batch Training tab gains **Mode** (Regular / Interactive Guessing), **Direction**, **Range**, and a **Learning** toggle — Learning off (either mode) means pure generation that teaches nothing. Combined with the new IMAGES output (all batch videos come out the Chain Sampler's IMAGES port), you can now generate N slight variations and just watch them without rating. + +### Changed + +The Chain Sampler's **IMAGES output now returns every batch video** concatenated, not just the last one — for both regular batches and Interactive Guessing. + **Latent normalization stacks on the Hybrid and Distilled samplers too.** The same video-only anti-overbake normalization from the Normalizing sampler is now an opt-in `normalize_strength` / `normalize_start_sigma` on the Hybrid Euler 2S (CONST/RF path) and Distilled Flow samplers, so you can keep their features (AB2, Heun, velocity bias, rescue) *and* get the latent-health correction. Default off (no behaviour change); audio is never touched. Exposed on both nodes and in their Studio panels. **FunPack Normalizing Sampler** — a new `SAMPLER` (selectable in Studio alongside Hybrid Euler 2S / Distilled Flow / KSampler) built for distilled few-step LTXAV at CFG=1, where the model is already extracted and there's no redundant CFG computation to exploit. It runs a clean deterministic 1st-order euler base (the "ddim look" + clean audio — no AB2/Heun/ancestral) and adds **video-only latent normalization**: each step it tracks the video latent's robust spread, anchors a reference once x0 is meaningful (`normalize_start_sigma`), and gently compresses any later step whose spread inflates past it (`normalize_strength`), countering the overbaking / oversaturation / colour-drift that distilled latents fall into. Benefit-only (never amplifies, no-op when the latent is healthy), ~zero overhead (a few masked reductions), and audio-safe by construction — the audio stream rides plain euler and the normalization never touches it (whole-latent for single-stream LTXV). Velocity bias + reactive rescue are available too, video-only. Node `FunPackNormalizingSampler` + Studio sampler panel. diff --git a/batch_training.py b/batch_training.py index 60d724e8..4fc76ccc 100644 --- a/batch_training.py +++ b/batch_training.py @@ -141,6 +141,92 @@ def _train_batch(key, manifest, batch_dir, ratings): return trained, axis_note +def _store_guess_ceiling(key, factor, spread, direction): + """Write the learned Interactive-Guessing safe ceiling into the refiner state (global) for + `key`, preserving everything else. guess_safe_spread is the absolute cap the refiner clamps + future output conditioning to; guess_safe_factor is for display.""" + try: + try: + from .conditioning import refinement_state_path + except ImportError: + from conditioning import refinement_state_path + path = refinement_state_path(key, "clip", prefix="refine_v2") + data = {} + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + data = {} + data.setdefault("version", 2) + g = data.setdefault("global", {}) + g["guess_safe_factor"] = float(factor) + if spread is not None: + g["guess_safe_spread"] = float(spread) + g["guess_direction"] = str(direction) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) + return True + except Exception as e: + print(f"[FunPack guess] store ceiling failed: {e}") + return False + + +def _learn_guess_ceiling(key, manifest, batch_dir, ratings): + """Interactive Guessing learning: from the rated ladder, find the safe steering ceiling — the + furthest-pushed rung still rated acceptable before quality broke — and store it (absolute + spread + factor) so the refiner auto-caps future steering. Reward >= 0 = acceptable.""" + try: + from .conditioning import conditioning_spread + except ImportError: + try: + from conditioning import conditioning_spread + except Exception: + conditioning_spread = lambda c: None + rated = [] + for item in manifest.get("items", []): + gf = item.get("guess_factor") + if gf is None: + continue + label = ratings.get(str(item.get("id"))) + if not label: + continue + prof = _profile_for(label) + if not prof or prof.get("skip_learning"): + continue + rated.append((float(gf), float(prof.get("reward", 0.0)), item.get("cond"))) + if not rated: + return "Interactive Guessing: no rated rungs — nothing learned." + up = sum(1 for f, _, _ in rated if f > 1.0) >= sum(1 for f, _, _ in rated if f < 1.0) + broke = [f for f, r, _ in rated if r < 0.0] + ok = [(f, c) for f, r, c in rated if r >= 0.0] + if up: + fail = min(broke) if broke else float("inf") + candidates = [(f, c) for f, c in ok if f < fail] + safe = max(candidates, key=lambda x: x[0]) if candidates else None + else: + fail = max(broke) if broke else float("-inf") + candidates = [(f, c) for f, c in ok if f > fail] + safe = min(candidates, key=lambda x: x[0]) if candidates else None + if safe is None: + # even the base broke — cap at base (1.0), no headroom + _store_guess_ceiling(key, 1.0, None, "up" if up else "down") + return "Interactive Guessing: every rung broke — capped steering to base (×1.00)." + safe_factor, safe_cond_file = safe + safe_spread = None + if safe_cond_file: + cond_path = os.path.join(batch_dir, safe_cond_file) + if os.path.exists(cond_path): + try: + with torch.inference_mode(False): + safe_spread = conditioning_spread(torch.load(cond_path, map_location="cpu")) + except Exception: + safe_spread = None + _store_guess_ceiling(key, safe_factor, safe_spread, "up" if up else "down") + return (f"Interactive Guessing: safe up to ×{safe_factor:.2f}" + + (f" (spread {safe_spread:.3f}) — future steering auto-capped." if safe_spread + else " — future steering auto-capped.")) + + if PromptServer is not None and web is not None: @PromptServer.instance.routes.get("/funpack/batch/list") @@ -164,6 +250,7 @@ if PromptServer is not None and web is not None: "seed": it.get("seed"), "preview": it.get("preview"), "variant": it.get("variant"), + "guess_factor": it.get("guess_factor"), "prompt": it.get("prompt"), "rating": it.get("rating"), } for it in manifest.get("items", [])] @@ -187,13 +274,22 @@ if PromptServer is not None and web is not None: key = str(body.get("key", "")).strip() stamp = str(body.get("stamp", "")).strip() ratings = body.get("ratings", {}) or {} + learn = bool(body.get("learn", True)) if not key or not stamp: return web.json_response({"error": "key and stamp required"}, status=400) batch_dir = os.path.join(_key_dir(key), stamp) if not os.path.exists(os.path.join(batch_dir, "batch.json")): return web.json_response({"error": "batch not found"}, status=404) manifest = _load_manifest(batch_dir) - trained, note = _train_batch(key, manifest, batch_dir, {str(k): v for k, v in ratings.items()}) + ratings = {str(k): v for k, v in ratings.items()} + is_guess = any(it.get("guess_factor") is not None for it in manifest.get("items", [])) + if not learn: + trained, note = 0, "learning off — generated only, nothing learned." + elif is_guess: + note = _learn_guess_ceiling(key, manifest, batch_dir, ratings) + trained = 1 if "safe" in (note or "") else 0 + else: + trained, note = _train_batch(key, manifest, batch_dir, ratings) # Submit clears the batch regardless (rated material is consumed). try: shutil.rmtree(batch_dir, ignore_errors=True) diff --git a/conditioning.py b/conditioning.py index 84368c88..ac714d9d 100644 --- a/conditioning.py +++ b/conditioning.py @@ -11517,6 +11517,17 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): global_state.setdefault("phrase_memory", {}) global_state.setdefault("axis_conditioning_memory", {}) global_state.setdefault("negative_prompt_memory", {}) + + # Interactive-Guessing dual auto-cap (learned safe ceiling). Applied on NORMAL runs only — + # never during the guessing ladder itself, which deliberately sweeps the spread. + _guess_spread_cap = None + if not guess_mode: + _gsf = float(global_state.get("guess_safe_factor", 0) or 0) + if _gsf > 0: + absolute_strength = min(float(absolute_strength), _gsf) # (a) soft strength pre-limit + _gss = float(global_state.get("guess_safe_spread", 0) or 0) + if _gss > 0: + _guess_spread_cap = _gss # (b) output-spread clamp (the guarantee) global_state.setdefault("lora_weight_memory", {}) global_state.setdefault("preferred_context_memory", {}) global_state.setdefault("intent_alignment_memory", {}) @@ -12323,7 +12334,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): _concept_strength, _current_final, guess_mode=guess_mode, guess_direction=guess_direction, guess_range=guess_range) return ( - self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength), + self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength, spread_cap=_guess_spread_cap), status, training_info, loss_graph, @@ -12341,7 +12352,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): repair_feedback, current_family_slot, _vf_for_memory, _concept_dir, _concept_strength, _current_final) return ( - self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength), + self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength, spread_cap=_guess_spread_cap), status + enhancement_status, training_info, loss_graph, @@ -12701,15 +12712,31 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): return out def _v2_finalize_conditioning(self, conditioning_list, refinement_key, value_guidance, - steer_mode, absolute_strength): + steer_mode, absolute_strength, spread_cap=None): """Single output hook for both steering modes. Relative = per-key VF ascend (current - behaviour). Absolute = global taste pull. Both = layer them.""" + behaviour). Absolute = global taste pull. Both = layer them. Finally, if Interactive + Guessing has learned a safe-spread ceiling, clamp the output conditioning's video-channel + spread to it (the backstop half of the dual auto-cap).""" mode = str(steer_mode or "relative").lower() out = conditioning_list if mode in ("relative", "both"): out = self._v2_ascend_conditioning(out, refinement_key, apply=value_guidance) if mode in ("absolute", "both"): out = self._v2_apply_absolute(out, float(absolute_strength)) + if spread_cap and spread_cap > 0: + capped = [] + clamped_any = False + for entry in out: + if isinstance(entry, (list, tuple)) and len(entry) >= 2 and isinstance(entry[0], torch.Tensor): + sp = conditioning_spread(entry[0]) + if sp and sp > spread_cap: + entry = (scale_conditioning_spread(entry[0], spread_cap / sp), entry[1]) + clamped_any = True + capped.append(entry) + out = capped + if clamped_any: + print(f"[FunPackRefiner] Interactive-Guessing cap: output conditioning spread " + f"clamped to learned-safe {spread_cap:.3f}.") return out diff --git a/web/funpack_studio.js b/web/funpack_studio.js index cc882728..af134c95 100644 --- a/web/funpack_studio.js +++ b/web/funpack_studio.js @@ -559,23 +559,62 @@ function openBatchPanel(node) { function renderConfig() { const settings = getSettings(node); - const n = parseInt(settings?.refiner?.batch_variants || 1, 10) || 1; + const rf = settings.refiner = settings.refiner || {}; + const n = parseInt(rf.batch_variants || 1, 10) || 1; body.append(el("div", "funpack-studio-hint", - "A batch is a set of generations made in one run with everything frozen except the seed (and shortcut options, if your prompt has any). Set the size and Arm it, then press ComfyUI's Generate. When it finishes, reopen this window to rate each one.")); + "A batch is a set of generations made in one run. Regular = everything frozen except the seed (and shortcut options). Interactive Guessing = freeze EVERYTHING including the seed and sweep the conditioning strength up or down across the batch, so you can see exactly where it breaks. Set it up, Arm, then press ComfyUI's Generate. All videos come out the Chain Sampler's IMAGES output; reopen this window to rate them.")); + const sizeInput = numInput(n > 1 ? n : 10, 2, 64, 1); body.append(row("Batch size", sizeInput)); - body.append(el("div", "funpack-studio-hint", n > 1 ? `Armed: the next Generate produces ${n}.` : "Not armed — Generate runs a single normal generation.")); + + if (!rf.batch_mode) rf.batch_mode = "Regular"; + const modeSelect = selectEl(["Regular", "Interactive Guessing"], rf.batch_mode); + modeSelect.addEventListener("change", () => { rf.batch_mode = modeSelect.value; saveSettings(node, settings); render(); }); + body.append(row("Mode", modeSelect)); + + let dirSelect = null, rangeInput = null; + if (rf.batch_mode === "Interactive Guessing") { + if (!rf.guess_direction) rf.guess_direction = "up"; + dirSelect = selectEl(["up", "down"], rf.guess_direction); + body.append(el("div", "funpack-studio-hint", "up = amplify the conditioning's spread toward overbake (find your safe ceiling). down = dampen it (find how flat is too flat).")); + body.append(row("Direction", dirSelect)); + if (rf.guess_range === undefined) rf.guess_range = 1.0; + rangeInput = numInput(rf.guess_range, 0.1, 2.0, 0.1); + body.append(el("div", "funpack-studio-hint", "How far the last rung goes: up → 1.0+range (e.g. 1.0 = up to 2.0×). down → 1.0−range (capped above 0). The ladder ramps linearly from the untouched base to this.")); + body.append(row("Range", rangeInput)); + } + + if (rf.batch_learning === undefined) rf.batch_learning = true; + const learnToggle = toggleEl(!!rf.batch_learning, "Learn from ratings on Submit"); + learnToggle.inp.addEventListener("change", () => { rf.batch_learning = learnToggle.inp.checked; saveSettings(node, settings); }); + body.append(el("div", "funpack-studio-hint", rf.batch_mode === "Interactive Guessing" + ? "On: rating the ladder learns your safe-spread ceiling and auto-caps future steering for this key. Off: pure generation, nothing learned." + : "On: rating trains the value function + repair memory. Off: pure generation — make variations without teaching anything.")); + body.append(row("Learning", learnToggle.wrap)); + + body.append(el("div", "funpack-studio-hint", n > 1 + ? `Armed (${rf.batch_mode}): the next Generate produces ${n}.` + : "Not armed — Generate runs a single normal generation.")); const actions = el("div", ""); actions.style.cssText = "display:flex;gap:6px;margin-top:8px;"; const armBtn = btn(n > 1 ? "Re-arm" : "Arm batch"); armBtn.addEventListener("click", () => { const v = Math.max(2, Math.min(64, parseInt(sizeInput.value, 10) || 0)); - const s = getSettings(node); s.refiner = s.refiner || {}; s.refiner.batch_variants = v; saveSettings(node, s); + const s = getSettings(node); s.refiner = s.refiner || {}; + s.refiner.batch_variants = v; + s.refiner.batch_mode = modeSelect.value; + s.refiner.guess_mode = (modeSelect.value === "Interactive Guessing"); + if (dirSelect) s.refiner.guess_direction = dirSelect.value; + if (rangeInput) s.refiner.guess_range = Math.max(0.1, Math.min(2.0, parseFloat(rangeInput.value) || 1.0)); + s.refiner.batch_learning = learnToggle.inp.checked; + saveSettings(node, s); refreshBatchButton(node); render(); }); const offBtn = btn("Disarm", "secondary"); offBtn.addEventListener("click", () => { - const s = getSettings(node); s.refiner = s.refiner || {}; s.refiner.batch_variants = 1; saveSettings(node, s); + const s = getSettings(node); s.refiner = s.refiner || {}; + s.refiner.batch_variants = 1; s.refiner.guess_mode = false; + saveSettings(node, s); refreshBatchButton(node); render(); }); actions.append(armBtn, offBtn); @@ -590,7 +629,7 @@ function openBatchPanel(node) { data.items.forEach((it) => { const r = el("div", ""); r.style.cssText = "display:flex;align-items:center;gap:6px;margin:2px 0;"; - const vtag = (it.variant != null) ? `[v${it.variant}] ` : ""; + const vtag = (it.guess_factor != null) ? `[×${it.guess_factor}] ` : ((it.variant != null) ? `[v${it.variant}] ` : ""); const lab = it.prompt ? it.prompt : (it.preview || it.id); const name = el("span", "", `#${it.index ?? 0} ${vtag}${lab} (seed ${it.seed})`); name.title = it.preview || it.id; @@ -616,7 +655,8 @@ function openBatchPanel(node) { if (Object.keys(ratings).length === 0) { errorEl.textContent = "Rate at least one generation first."; return; } submitBtn.disabled = true; try { - const res = await api.fetchApi("/funpack/batch/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, stamp: data.stamp, ratings }) }); + const learn = (getSettings(node)?.refiner?.batch_learning !== false); + const res = await api.fetchApi("/funpack/batch/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, stamp: data.stamp, ratings, learn }) }); const out = await res.json(); if (out.error) throw new Error(out.error); errorEl.textContent = `Submitted — trained on ${out.trained}.${out.info ? " " + out.info : ""}`; @@ -2127,6 +2167,7 @@ app.registerExtension({ const s = getSettings(n); // one-shot: disarm after the batch ran if (s?.refiner && parseInt(s.refiner.batch_variants || 1, 10) > 1) { s.refiner.batch_variants = 1; + s.refiner.guess_mode = false; saveSettings(n, s); } }