diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 544a28a0..d7b58515 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -200,6 +200,7 @@ // H3 representation steering (Settings ▸ Learning) — per-KEY, unlike the probe above. reinsStatus: (key) => j("GET", API("/h3_repr_steering") + `?key=${encodeURIComponent(key || "default")}`), + reinsSweep: (key, trials) => j("POST", API("/h3_repr_steering/sweep"), { key: key || "default", trials: trials || 2000 }), reinsClear: (key) => j("POST", API("/h3_repr_steering/clear"), { key: key || "default" }), async reinsExport(key) { const k = key || "default"; diff --git a/movie_editor/frontend/settings_window.js b/movie_editor/frontend/settings_window.js index a72cb0c3..56b71325 100644 --- a/movie_editor/frontend/settings_window.js +++ b/movie_editor/frontend/settings_window.js @@ -393,7 +393,13 @@ // ── H3 representation steering ────────────────────────────────────────── // Per-KEY (unlike the probe above, which pools across every key): one sidecar per // refinement key, so status/export/clear all act on whichever key this project is using. - let reinsState = null, reinsError = ""; + // + // The block sweep is a 9-block permutation test -- heavy enough on CPU that running it on + // every panel mount (status was doing this) stalled the event loop ComfyUI's own server + // shares with this one, confirmed live as a generation freezing while the panel was open. + // Split the same way the probe already is: cheap status auto-fetched, the actual sweep + // only on explicit request (the "Read" button below). + let reinsState = null, reinsSweep = null, reinsSweepBusy = false, reinsError = ""; function reinsSweepTable(sweep) { const entries = Object.entries(sweep || {}).sort((a, b) => a[1].p_value - b[1].p_value); @@ -439,17 +445,27 @@ if (!window.confirm(`Throw away all REINS data for '${key}'?\n\nThis cannot be ` + "undone. Download it first if you want to keep it.")) return; reinsError = ""; - try { reinsState = await window.MovieEditorAPI.reinsClear(key); } + try { reinsState = await window.MovieEditorAPI.reinsClear(key); reinsSweep = null; } catch (e) { reinsError = String(e.message || e); } paint(); }, { disabled: !nLiked && !nDisliked, danger: true })); + rows.append(actionRow("Read the block sweep", + "Runs a permutation test at every candidate block to see which one actually " + + "separates liked from disliked. Heavy enough on CPU to notice — not run " + + "automatically, only when you ask for it.", + reinsSweepBusy ? "Reading…" : "Read", async () => { + reinsSweepBusy = true; reinsError = ""; paint(); + try { reinsSweep = await window.MovieEditorAPI.reinsSweep(key, 2000); } + catch (e) { reinsError = String(e.message || e); } + reinsSweepBusy = false; paint(); + }, { disabled: reinsSweepBusy || (!nLiked && !nDisliked) })); box.append(rows); if (reinsError) box.append(el("div", "sw-hint", reinsError)); - const sweepTbl = reinsState && reinsSweepTable(reinsState.sweep); + const sweepTbl = reinsSweep && reinsSweepTable(reinsSweep.sweep); if (sweepTbl) { box.append(el("div", "sw-rows-label", `Block sweep — which of H3's blocks separates liked from disliked (block ` - + `${reinsState.default_block} is the one that actually steers)`)); + + `${reinsState ? reinsState.default_block : "?"} is the one that actually steers)`)); box.append(sweepTbl); } }; diff --git a/movie_editor/server.py b/movie_editor/server.py index c6c15890..dfe8e18c 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -2099,9 +2099,14 @@ if web is not None and PromptServer is not None: return rs def _repr_steer_state(key): + # Deliberately NOT block_sweep() -- that's a 9-block x trials permutation test, heavy + # enough on CPU to stall the event loop this route shares with ComfyUI's own server + # (confirmed live: opening the panel froze a generation in progress). Same split + # trajectory_probe already uses -- cheap status here, the actual analysis behind its + # own explicitly-triggered route below, so a status fetch (which fires on every panel + # mount, not just on request) can never be the expensive one. rs = _repr_steer_module() _direction, n_liked, n_disliked = rs.direction(key) - sweep = rs.block_sweep(key) return rs, { "key": key, "n_liked": n_liked, @@ -2109,7 +2114,6 @@ if web is not None and PromptServer is not None: "ready": _direction is not None, "min_per_group": rs.MIN_PER_GROUP, "default_block": rs.DEFAULT_BLOCK, - "sweep": {str(b): r for b, r in sweep.items()}, } @routes.get(UI_PREFIX + "/api/h3_repr_steering") @@ -2122,6 +2126,26 @@ if web is not None and PromptServer is not None: return web.json_response({"key": key, "n_liked": 0, "n_disliked": 0, "ready": False, "error": str(e)}) + @routes.post(UI_PREFIX + "/api/h3_repr_steering/sweep") + async def _repr_steer_sweep(req): + try: + body = await req.json() + except Exception: + body = {} + key = str(body.get("key") or "default").strip() or "default" + trials = max(100, min(20000, int(body.get("trials") or 2000))) + rs = _repr_steer_module() + try: + # Off the event loop -- this is the actual CPU-heavy permutation test, and even + # triggered explicitly (not on every panel mount, unlike before) it should not be + # able to stall other requests, including a generation's own progress messages, + # while it runs. + import asyncio + sweep = await asyncio.to_thread(rs.block_sweep, key, trials) + return web.json_response({"key": key, "sweep": {str(b): r for b, r in sweep.items()}}) + except Exception as e: + return web.json_response({"key": key, "sweep": {}, "error": str(e)}, status=500) + # Export streams the sidecar FILE directly rather than reconstructing it from rows: this # module's state already IS the export format (rows keyed by block + weight + prompt_hash), # unlike the probe's aggregate-across-keys log.