From d1458eb681e565c07497a6b8ce367f3c6128df64 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:52:52 +0300 Subject: [PATCH] Stage 1: repoint attribution + reset onto canonical split; remove band-aids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_scene_refinement_keys now derives purely from split_scenes — drops the scene_count parameter AND both divergence fallbacks (the band-aids). Generation reads per-scene keys from the canonical raw split and GUARDS that its text-split count (prompt_to_encode) matches; on mismatch it screams and falls back to default-key attribution rather than land a key on the wrong scene. bridge.scene_refinement_keys + the /preview callers drop scene_count. Reset (_v2_reset_prompt_keys) now wipes refinement_key_pool_for(prompt), so the pool is one canonical answer shared with the reset-confirmation modal. That removes the last user of prompt_scene_shortcut_keys, so its entire internal split is deleted (and its 3 tests, covered by split_scenes tests). Tests: 20 refinement-key + 140 movie_editor pass. Generation path needs an in-app smoke test (no model here) — the guard makes a desync loud, not silent. Co-Authored-By: Claude Opus 4.8 --- conditioning.py | 154 ++++++++------------------------- movie_editor/backend/bridge.py | 15 ++-- movie_editor/server.py | 4 +- tests/test_refinement_keys.py | 65 +++----------- 4 files changed, 56 insertions(+), 182 deletions(-) diff --git a/conditioning.py b/conditioning.py index 63852091..1b76f8f0 100644 --- a/conditioning.py +++ b/conditioning.py @@ -815,78 +815,6 @@ def split_timeline_verbatim(prompt): return {"anchor": chunks[0], "scenes": scenes, "transitions": transitions} -def prompt_scene_shortcut_keys(prompt): - """Map fired shortcuts to scenes by their non-default refinement key. - - Returns (scene_count, scene_key_sets, all_keys) where scene_key_sets[i] is the set of - non-default refinement keys whose shortcuts fired in scene i's text, and all_keys is the - union (used for the safe fallback when scene attribution can't be trusted). Boundaries - mirror the editor's lossless splitter (split_timeline_verbatim): expand-with-map -> - detect transitions -> project boundaries onto the ORIGINAL text. The anchor (chunk 0) is - prepended to every scene, so anchor-span keys participate in EVERY scene. Original-text - offsets are used so random replacement picks (which change expanded lengths) never matter. - - scene_count == 0 means no transition split was detected (single scene) or there were no - fired non-default keys at all. - """ - text = str(prompt or "") - if not text.strip(): - return 0, [], set() - - expanded, pieces = _expand_with_map(text) - fired = [(pc.get("orig_start", 0), str(pc.get("refinement_key") or "")) - for pc in pieces - if pc.get("is_shortcut") and str(pc.get("refinement_key") or "")] - all_keys = {k for _, k in fired} - if not all_keys: - return 0, [], set() - - try: - from .templates import load_custom_transition_triggers - except ImportError: - from templates import load_custom_transition_triggers - custom_map = load_custom_transition_triggers() - triggers = list(custom_map.keys()) - if triggers: - def _trig_pat(t): - end = r"\b" if re.search(r"\w$", t) else r"(?=\s|$)" - return r"\b" + re.escape(t) + end - parts = "|".join(_trig_pat(t) for t in sorted(triggers, key=len, reverse=True)) - split_pattern = re.compile(r"(?:" + parts + r"|" + _GENERIC_SCENE_LABEL_PATTERN + r")", re.IGNORECASE) - else: - split_pattern = re.compile(_GENERIC_SCENE_LABEL_PATTERN, re.IGNORECASE) - - cuts = [] - for m in split_pattern.finditer(expanded): - info = custom_map.get(re.sub(r"\s+", " ", m.group(0).strip().lower())) or {} - if info.get("placement") == "start": - orig = _project_offset(pieces, m.start(), "before") - else: - orig = _project_offset(pieces, m.end(), "after") - cuts.append(orig) - seen, uniq = set(), [] - for off in sorted(cuts): - if off <= 0 or off in seen: - continue - seen.add(off); uniq.append(off) - if not uniq: - return 0, [], all_keys # no scene split -> caller uses default / fallback - - positions = [0] + uniq + [len(text)] - n_chunks = len(positions) - 1 # chunk 0 = anchor, chunks 1.. = scenes - chunk_keys = [set() for _ in range(n_chunks)] - for off, key in fired: - idx = 0 - for i in range(n_chunks): - if positions[i] <= off < positions[i + 1]: - idx = i - break - chunk_keys[idx].add(key) - anchor_keys = chunk_keys[0] - scene_key_sets = [set(anchor_keys) | s for s in chunk_keys[1:]] - return len(scene_key_sets), scene_key_sets, all_keys - - def refinement_keys_in_text(text): """The set of non-default refinement keys whose shortcuts fire in `text`. @@ -960,46 +888,20 @@ def refinement_key_pool_for(prompt): return pool -def resolve_scene_refinement_keys(raw_positive, scene_count): - """Per-scene non-default refinement keys, read straight off each scene's text. +def resolve_scene_refinement_keys(raw_positive): + """Per-scene non-default refinement keys, read straight off the canonical scene split. - The honest, simple model (no scene-count comparison, no union/empty fallback guessing): - 1. Split the prompt into anchor + scenes with the SAME shortcut-aware splitter the editor - and Studio use (`split_timeline_verbatim`), so scenes line up with generation. - 2. For each scene, read the keys whose shortcuts fired in it; anchor keys apply to every - scene (the anchor is prepended to all of them). - A scene with no fired non-default key gets an empty set => it steers/trains with the project - DEFAULT key. Returns a list of sets; empty sets => default. - - Single source of truth shared by generation (FunPackStudio._v2_scene_refinement_keys) and the - Movie Editor preview, so what the editor shows is exactly what will run.""" - scene_count = max(1, int(scene_count or 1)) - raw = str(raw_positive or "") + Returns a list of sets aligned 1:1 with `split_scenes(raw_positive)["scenes"]`; an empty set + means that scene steers/trains with the project DEFAULT key. There is no scene-count parameter + and no divergence fallback: every consumer reads the ONE canonical split, so attribution can't + disagree with the preview or with generation. Anchor keys are already folded into each scene by + `split_scenes`.""" try: - split = split_timeline_verbatim(raw) - anchor_keys = refinement_keys_in_text(split.get("anchor", "")) - per_scene = [anchor_keys | refinement_keys_in_text(s.get("text", "")) - for s in (split.get("scenes", []) or [])] + return [scene.get("keys") or set() + for scene in split_scenes(str(raw_positive or "")).get("scenes", []) or []] except Exception as error: print(f"[FunPackStudio] Scene refinement-key attribution failed: {error}") - return [set() for _ in range(scene_count)] - - pool = set(anchor_keys) - for s in per_scene: - pool |= s - if not pool: - # No keyed shortcuts anywhere -> every scene is on the default key. - return [set() for _ in range(scene_count)] - if len(per_scene) == scene_count: - # Scenes line up with the caller's split -> precise per-scene attribution. - return per_scene - if scene_count == 1: - # One logical scene -> every activated key belongs to it. - return [pool] - # The split we read disagrees with the caller's scene count (advisor/repair restructured the - # prompt between attribution and generation). We can't trust which scene each key lands in, so - # steer/train the project DEFAULT key only rather than smear every key across every scene. - return [set() for _ in range(scene_count)] + return [] def parse_timeline_segments(prompt): @@ -6605,11 +6507,9 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): keys = set() for text in raw_prompts: try: - _, _, all_keys = prompt_scene_shortcut_keys(text) + keys |= refinement_key_pool_for(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 = [] @@ -6942,12 +6842,12 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): conditionings.append((cond, scene_meta)) return conditionings - def _v2_scene_refinement_keys(self, raw_positive, scene_count): - """Per-scene non-default refinement keys, aligned to split_scene_texts. + def _v2_scene_refinement_keys(self, raw_positive): + """Per-scene non-default refinement keys, aligned to the canonical split_scenes(). Thin wrapper over the module-level resolve_scene_refinement_keys so generation and the Movie Editor preview share one source of truth (see that function for the rules).""" - return resolve_scene_refinement_keys(raw_positive, scene_count) + return resolve_scene_refinement_keys(raw_positive) def _v2_scene_seed_values(self, seed, scene_count, provided=None): scene_count = max(0, int(scene_count or 0)) @@ -12735,12 +12635,26 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): except Exception as e: print(f"[FunPackVideoRefinerV2] Transition seed prep failed: {e}") - # Per-scene non-default refinement keys (shortcut->key bindings). Aligned to - # split_scene_texts; empty sets keep today's single-key behaviour for that scene. - scene_refinement_keys = ( - self._v2_scene_refinement_keys(_raw_positive_prompt, len(split_scene_texts)) - if split_scene_texts else [] - ) + # Per-scene non-default refinement keys (shortcut->key bindings), read off the ONE canonical + # split of the RAW prompt. The scene TEXTS above come from prompt_to_encode (post any + # transforms); the KEYS come from the raw prompt where shortcuts still live. GUARD: those two + # must agree on scene count — if a transform (advisor/lucky/wildcard; prompt-repair is being + # removed) restructured the prompt between them, the per-scene key->scene mapping can't be + # trusted, so scream and fall back to default-key attribution rather than land a key on the + # wrong scene. With no structure-changing transforms (the common case) the counts always match. + scene_refinement_keys = [] + if split_scene_texts: + canonical_scene_keys = self._v2_scene_refinement_keys(_raw_positive_prompt) + if len(canonical_scene_keys) == len(split_scene_texts): + scene_refinement_keys = canonical_scene_keys + else: + print( + f"[FunPackVideoRefinerV2] SCENE SPLIT MISMATCH: text split has " + f"{len(split_scene_texts)} scene(s) but the raw key split has " + f"{len(canonical_scene_keys)} — a prompt transform changed the scene structure. " + f"Falling back to default-key attribution for this run." + ) + scene_refinement_keys = [set() for _ in split_scene_texts] if current_prompt_refusal: state["last_run"] = None diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py index fb090aca..1c6afce4 100644 --- a/movie_editor/backend/bridge.py +++ b/movie_editor/backend/bridge.py @@ -82,16 +82,16 @@ def parse_timeline_verbatim(prompt: str) -> dict: return parse_timeline_raw(prompt) -def scene_refinement_keys(prompt: str, scene_count: int, default_key: str = "default") -> list: +def scene_refinement_keys(prompt: str, default_key: str = "default") -> list: """Per-scene refinement keys that generation will actually use, for the editor preview. - Returns one entry per scene aligned to parse_timeline's scenes: + Returns one entry per scene aligned to the canonical split_scenes(): {"keys": [], "uses_default": bool, "default_key": str} A scene with no fired non-default keys steers with the project default key (uses_default=True). Mirrors generation exactly by reusing the same resolver.""" resolve = _funpack_attr("conditioning", "resolve_scene_refinement_keys") default_key = str(default_key or "default").strip() or "default" - sets = resolve(str(prompt or ""), int(scene_count or 1)) + sets = resolve(str(prompt or "")) out = [] for s in sets: keys = sorted(s) @@ -105,12 +105,11 @@ def scene_refinement_keys(prompt: str, scene_count: int, default_key: str = "def def refinement_key_pool(prompt: str) -> list: """Every non-default refinement key whose shortcut fires anywhere in the prompt — the exact set a Studio session reset will wipe. Scene-count independent, so it mirrors the backend - `_v2_reset_prompt_keys` (which clears `prompt_scene_shortcut_keys(...).all_keys`), NOT the - per-scene `scene_refinement_keys` (whose divergence fallback can drop a key the reset still - wipes). Used so the reset confirmation lists exactly what will be cleared.""" - keys_in_text = _funpack_attr("conditioning", "refinement_keys_in_text") + `_v2_reset_prompt_keys`. Used so the reset confirmation lists exactly what will be cleared. + Derived from the one canonical split, like everything else.""" + pool_for = _funpack_attr("conditioning", "refinement_key_pool_for") try: - return sorted(k for k in keys_in_text(str(prompt or "")) if k) + return sorted(k for k in pool_for(str(prompt or "")) if k) except Exception: return [] diff --git a/movie_editor/server.py b/movie_editor/server.py index 461c3f89..49ee39fc 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -1143,7 +1143,7 @@ if web is not None and PromptServer is not None: result["expected_scenes"] = validation["expected_scenes"] result["parsed_scenes"] = len(parsed.get("scenes", [])) result["scene_refinement_keys"] = bridge.scene_refinement_keys( - prompt, len(parsed.get("scenes", [])), p.refinement_key) + prompt, p.refinement_key) result["refinement_key_pool"] = bridge.refinement_key_pool(prompt) except Exception as e: # noqa: BLE001 result["parse_error"] = str(e) @@ -1161,7 +1161,7 @@ if web is not None and PromptServer is not None: result["expected_scenes"] = expected result["parsed_scenes"] = got result["scene_refinement_keys"] = bridge.scene_refinement_keys( - prompt, got, p.refinement_key) + prompt, p.refinement_key) result["refinement_key_pool"] = bridge.refinement_key_pool(prompt) if expected and got != expected: result["warning"] = ( diff --git a/tests/test_refinement_keys.py b/tests/test_refinement_keys.py index a2b2db94..f2523ce5 100644 --- a/tests/test_refinement_keys.py +++ b/tests/test_refinement_keys.py @@ -131,55 +131,15 @@ def test_expand_with_map_tags_pieces(monkeypatch): assert keyed == {"keyA", "keyB"} -def test_prompt_scene_shortcut_keys_per_scene(monkeypatch): - db = _db({"a": (["alpha"], ["AAA"], "keyA"), "b": (["beta"], ["BBB"], "keyB")}) - monkeypatch.setattr(templates, "load_shortcut_db", lambda: db) - monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) - # generic "scene N" labels split: chunk0=anchor (no key), chunk1=alpha->A, chunk2=beta->B - n, scene_sets, all_keys = conditioning.prompt_scene_shortcut_keys( - "wide shot scene 1 alpha here scene 2 beta here" - ) - assert n == 2 - assert scene_sets[0] == {"keyA"} - assert scene_sets[1] == {"keyB"} - assert all_keys == {"keyA", "keyB"} - - -def test_prompt_scene_shortcut_keys_anchor_in_every_scene(monkeypatch): - # anchor-bound key participates in every scene; per-scene keys add on top. - db = _db({"anc": (["zeta"], ["ZZZ"], "anchorKey"), - "a": (["alpha"], ["AAA"], "keyA"), - "b": (["beta"], ["BBB"], "keyB")}) - monkeypatch.setattr(templates, "load_shortcut_db", lambda: db) - monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) - n, scene_sets, _ = conditioning.prompt_scene_shortcut_keys( - "zeta intro scene 1 alpha here scene 2 beta here" - ) - assert n == 2 - assert scene_sets[0] == {"anchorKey", "keyA"} - assert scene_sets[1] == {"anchorKey", "keyB"} - - -def test_prompt_scene_shortcut_keys_no_keys(monkeypatch): - db = _db({"a": (["alpha"], ["AAA"], "")}) # default key only - monkeypatch.setattr(templates, "load_shortcut_db", lambda: db) - monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) - n, scene_sets, all_keys = conditioning.prompt_scene_shortcut_keys("alpha scene 2 stuff") - assert n == 0 and scene_sets == [] and all_keys == set() - - -def test_studio_scene_refinement_keys_fallback(monkeypatch): +def test_studio_scene_refinement_keys_canonical(monkeypatch): + """Attribution is read off the canonical split — one set per scene, no scene_count, no + divergence fallback (count reconciliation now lives in the generation guard, not here).""" db = _db({"a": (["alpha"], ["AAA"], "keyA"), "b": (["beta"], ["BBB"], "keyB")}) monkeypatch.setattr(templates, "load_shortcut_db", lambda: db) monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) studio = conditioning.FunPackVideoRefinerV2() - raw = "wide shot scene 1 alpha here scene 2 beta here" # attribution finds 2 scenes - # scene_count matches -> precise per-scene - assert studio._v2_scene_refinement_keys(raw, 2) == [{"keyA"}, {"keyB"}] - # scene_count diverges (advisor rewrote / stacking) -> default key only (empty sets), NOT the - # all-keys union; a union here would train every key on every scene (cross-contamination). - out = studio._v2_scene_refinement_keys(raw, 3) - assert out == [set(), set(), set()] + raw = "wide shot scene 1 alpha here scene 2 beta here" + assert studio._v2_scene_refinement_keys(raw) == [{"keyA"}, {"keyB"}] def test_studio_scene_refinement_keys_no_bindings(monkeypatch): @@ -187,7 +147,9 @@ def test_studio_scene_refinement_keys_no_bindings(monkeypatch): monkeypatch.setattr(templates, "load_shortcut_db", lambda: db) monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) studio = conditioning.FunPackVideoRefinerV2() - assert studio._v2_scene_refinement_keys("alpha scene 2 here", 2) == [set(), set()] + # no keyed shortcuts -> every scene is the default key (all empty sets) + out = studio._v2_scene_refinement_keys("alpha scene 2 here") + assert out and all(s == set() for s in out) def test_resolver_matches_studio(monkeypatch): @@ -197,8 +159,7 @@ def test_resolver_matches_studio(monkeypatch): monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) studio = conditioning.FunPackVideoRefinerV2() raw = "wide shot scene 1 alpha here scene 2 beta here" - for n in (2, 3): - assert conditioning.resolve_scene_refinement_keys(raw, n) == studio._v2_scene_refinement_keys(raw, n) + assert conditioning.resolve_scene_refinement_keys(raw) == studio._v2_scene_refinement_keys(raw) def test_reset_prompt_keys_union_excludes_primary(monkeypatch): @@ -267,11 +228,11 @@ def test_single_scene_with_custom_key_is_detected(monkeypatch): db = _db({"hero": (["hero"], ["a tall knight"], "mykey")}) monkeypatch.setattr(templates, "load_shortcut_db", lambda: db) monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) - assert conditioning.resolve_scene_refinement_keys("hero stands in a field", 1) == [{"mykey"}] + assert conditioning.resolve_scene_refinement_keys("hero stands in a field") == [{"mykey"}] # two custom keys in one scene -> both detected db2 = _db({"hero": (["hero"], ["knight"], "mykey"), "vil": (["villain"], ["masked"], "badguy")}) monkeypatch.setattr(templates, "load_shortcut_db", lambda: db2) - assert conditioning.resolve_scene_refinement_keys("hero meets villain", 1) == [{"mykey", "badguy"}] + assert conditioning.resolve_scene_refinement_keys("hero meets villain") == [{"mykey", "badguy"}] def test_resolve_reads_keys_per_scene(monkeypatch): @@ -280,7 +241,7 @@ def test_resolve_reads_keys_per_scene(monkeypatch): db = _db({"a": (["alpha"], ["AAA"], "keyA"), "b": (["beta"], ["BBB"], "keyB")}) monkeypatch.setattr(templates, "load_shortcut_db", lambda: db) monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) - out = conditioning.resolve_scene_refinement_keys("wide scene 1 alpha here scene 2 beta here", 2) + out = conditioning.resolve_scene_refinement_keys("wide scene 1 alpha here scene 2 beta here") assert out == [{"keyA"}, {"keyB"}] @@ -344,6 +305,6 @@ def test_bridge_scene_refinement_keys_preview(monkeypatch): db = _db({"a": (["alpha"], ["AAA"], "keyA")}) monkeypatch.setattr(templates, "load_shortcut_db", lambda: db) monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {}) - out = bridge.scene_refinement_keys("wide shot scene 1 alpha here scene 2 plain", 2, "myproj") + out = bridge.scene_refinement_keys("wide shot scene 1 alpha here scene 2 plain", "myproj") assert out[0] == {"keys": ["keyA"], "uses_default": False, "default_key": "myproj"} assert out[1] == {"keys": ["myproj"], "uses_default": True, "default_key": "myproj"}