Stage 1: repoint attribution + reset onto canonical split; remove band-aids

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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-15 09:52:52 +03:00
co-authored by Claude Opus 4.8
parent 58a4f9b52b
commit d1458eb681
4 changed files with 56 additions and 182 deletions
+34 -120
View File
@@ -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
+7 -8
View File
@@ -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": [<non-default keys, sorted>], "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 []
+2 -2
View File
@@ -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"] = (
+13 -52
View File
@@ -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"}