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:
co-authored by
Claude Opus 4.8
parent
58a4f9b52b
commit
d1458eb681
+34
-120
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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"] = (
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user