diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e079a3..fecd349f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ **Per-scene refinement-key preview.** The timeline preview now shows, on each scene, the refinement key(s) it will actually steer with before you generate — explicit keys (with `(avg)` when more than one), or the project default key (greyed, `(default)`) when no shortcut key fired in that scene. The preview reuses the exact generation-time resolver (`resolve_scene_refinement_keys`), so what you see matches what runs, including the safe-union fallback. +**Session Reset now wipes every key a run trains, not just the default.** Because per-scene multi-key learning trains *every* refinement key whose shortcut fired in the prompt, a Studio Session Reset now clears all of those keys too (project/default key + each non-default key activated by a shortcut), so no stale per-key state survives a reset (`FunPackVideoRefinerV2._v2_reset_prompt_keys`). The Movie Editor's "Reset Studio session" now confirms first, listing exactly which keys will be wiped: *"This action will reset Studio learning for keys: default, key1, key2… To avoid resetting a non-default key, remove the shortcut that activates it from the prompt. Proceed?"* — disarming a mis-armed reset does not re-prompt. + ## [3.0.0] - 2026-06-11 ### Added diff --git a/conditioning.py b/conditioning.py index 3c3ab686..2c9decf4 100644 --- a/conditioning.py +++ b/conditioning.py @@ -6461,6 +6461,32 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): except (json.JSONDecodeError, OSError, ValueError): return self._v2_empty_state(refinement_key), "reset unreadable" + def _v2_reset_prompt_keys(self, primary_key, *raw_prompts): + """On Session Reset, also wipe every NON-default refinement key whose shortcut fired in + the prompt(s) — not just the project/default key. Any key a run can TRAIN (per-scene + multi-key learning) must also be resettable by that run, otherwise stale state for those + keys survives a reset. primary_key is excluded (the main flow already reset + will resave + it). Returns the extra keys reset, for the status string.""" + keys = set() + for text in raw_prompts: + try: + _, _, all_keys = prompt_scene_shortcut_keys(text) + except Exception as error: + print(f"[FunPackVideoRefinerV2] Reset key scan failed: {error}") + all_keys = set() + keys |= all_keys + keys.discard(str(primary_key or "default")) + keys.discard("") + done = [] + for key in sorted(keys): + try: + fresh_state, _ = self._v2_load_state(key, reset_session=True) + self._v2_save_state(fresh_state, key) + done.append(key) + except Exception as error: + print(f"[FunPackVideoRefinerV2] Reset of refinement key '{key}' failed: {error}") + return done + def _v2_save_state(self, data, refinement_key): path = self._v2_state_path(refinement_key) os.makedirs(os.path.dirname(path), exist_ok=True) @@ -11984,6 +12010,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): # Pre-shortcut prompt, kept so Batch Training can re-resolve shortcuts with different # seeds to build N variant conditionings (Studio owns conditioning production). _raw_positive_prompt = str(positive_prompt or "") + _raw_intent_prompt = str(user_intent_prompt or "") positive_prompt, shortcut_status, shortcut_applied = self._v2_apply_global_shortcuts(positive_prompt, seed=seed) user_intent_prompt, intent_shortcut_status, intent_shortcut_applied = self._v2_apply_global_shortcuts(user_intent_prompt, seed=seed) if intent_shortcut_applied: @@ -12006,6 +12033,10 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): advisor_clip = advisor_clip if advisor_clip is not None else clip prompt_only_mode = execution_mode == "Prompt only" state, state_status = self._v2_load_state(refinement_key, reset_session=reset_session) + if reset_session: + extra_reset = self._v2_reset_prompt_keys(refinement_key, _raw_positive_prompt, _raw_intent_prompt) + if extra_reset: + state_status = f"{state_status} (also reset keys: {', '.join(extra_reset)})" scene_db, scene_builder_status = self._v2_scene_builder_db(state) global_state = state.setdefault("global", {}) global_state.setdefault("phrase_memory", {}) diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 2b578d80..fe92989e 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -2786,10 +2786,31 @@ ); } + // Every refinement key a reset will wipe: the project/default key plus every non-default + // key whose shortcut fires in the current prompt (those keys also get TRAINED per scene, so + // a reset must clear them too). Mirrors the backend _v2_reset_prompt_keys union. + function sessionResetKeys() { + const pv = state.preview || {}; + const defKey = (state.project?.refinement_key || "default").trim() || "default"; + const out = new Set([defKey]); + (pv.scene_refinement_keys || []).forEach((rk) => { + if (rk && !rk.uses_default) (rk.keys || []).forEach((k) => { if (k) out.add(k); }); + }); + return [...out]; + } + // Toggle a pending Studio session reset — applied to the FIRST run of the next - // generation. Clicking again disarms it (in case of a mis-click). + // generation. Clicking again disarms it (in case of a mis-click). Arming asks for + // confirmation, listing every key it will wipe so non-default keys aren't lost silently. let _resetSessionPending = false; function resetStudioSession() { + if (!_resetSessionPending) { + const keys = sessionResetKeys(); + const msg = `This action will reset Studio learning for keys: ${keys.join(", ")}.\n\n` + + "It is applied on the next generation. To avoid resetting a non-default key, " + + "remove the shortcut that activates it from the prompt.\n\nProceed?"; + if (!confirm(msg)) return; + } _resetSessionPending = !_resetSessionPending; set({ resetSessionArmed: _resetSessionPending }); } diff --git a/tests/test_refinement_keys.py b/tests/test_refinement_keys.py index 280c87cc..a8f0b12f 100644 --- a/tests/test_refinement_keys.py +++ b/tests/test_refinement_keys.py @@ -133,6 +133,38 @@ def test_resolver_matches_studio(monkeypatch): assert conditioning.resolve_scene_refinement_keys(raw, n) == studio._v2_scene_refinement_keys(raw, n) +def test_reset_prompt_keys_union_excludes_primary(monkeypatch): + """Session reset wipes the project key (caller) PLUS every non-default key in the prompt.""" + db = _db({"a": (["alpha"], ["AAA"], "keyA"), "b": (["beta"], ["BBB"], "keyB"), + "c": (["gamma"], ["CCC"], "keyC")}) + monkeypatch.setattr(templates, "load_shortcut_db", lambda: db) + monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) + studio = conditioning.FunPackVideoRefinerV2() + reset_calls, saved = [], [] + monkeypatch.setattr(studio, "_v2_load_state", + lambda key, reset_session=False: (reset_calls.append((key, reset_session)) or ({}, "fresh"))) + monkeypatch.setattr(studio, "_v2_save_state", lambda data, key: saved.append(key)) + # keyA fires in positive, keyC fires in intent; primary "default" is excluded. + done = studio._v2_reset_prompt_keys("default", "alpha shot", "gamma intent") + assert done == ["keyA", "keyC"] + assert all(rs is True for _, rs in reset_calls) + assert sorted(k for k, _ in reset_calls) == ["keyA", "keyC"] + assert sorted(saved) == ["keyA", "keyC"] + + +def test_reset_prompt_keys_skips_when_primary_only(monkeypatch): + db = _db({"a": (["alpha"], ["AAA"], "keyA")}) + monkeypatch.setattr(templates, "load_shortcut_db", lambda: db) + monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) + studio = conditioning.FunPackVideoRefinerV2() + monkeypatch.setattr(studio, "_v2_load_state", lambda *a, **k: ({}, "fresh")) + monkeypatch.setattr(studio, "_v2_save_state", lambda *a, **k: None) + # primary key is keyA itself -> nothing extra to reset + assert studio._v2_reset_prompt_keys("keyA", "alpha shot") == [] + # no keyed shortcuts at all -> nothing extra + assert studio._v2_reset_prompt_keys("default", "plain prompt") == [] + + def test_bridge_scene_refinement_keys_preview(monkeypatch): """Preview payload: explicit keys vs project-default fallback per scene.""" from movie_editor.backend import bridge