editor: pass scene boundaries structurally — remove 'scene N' injection entirely

Root cause the user pushed on: the editor flattened all scenes into ONE prompt
string and Studio re-derived boundaries by parsing text, so a carry scene with no
transition needed an injected 'scene N' delimiter. The editor already KNOWS every
boundary (each generative unit is a scene) — it never needed to smuggle markers
into the prompt.

- timeline.build_combined_prompt: no longer injects anything (verbatim anchor +
  scene texts). New build_generation_scene_segments(project) lists the boundaries
  the editor already has (each unit's raw text, prefixed with its REAL transition
  so effects survive; carry = plain text).
- conditioning.split_scenes_from_segments: canonical split from an explicit scene
  list (reuses split_scenes per item for expansion/keys/effect, folds the anchor),
  same output shape as split_scenes. refine_v2 gains scene_segments and uses it
  when present (count-preserving: a text-less i2v scene keeps its slot, no anchor
  shift); falls back to text-splitting only for raw node-pack use.
- server/builder: editor sends scene_segments -> studio_settings.refiner.scenes.

Net: the generation prompt Studio encodes is now exactly what the user typed —
no 'scene N' anywhere, in the prompt or the conditioning. Tests updated to assert
the verbatim prompt + structural segments; new tests for split_scenes_from_segments
(boundaries, empty-scene preservation, anchor/key fold). Fallback path unchanged
(stash-verified zero regression). NOTE: montage generation needs GPU verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-21 18:50:11 +03:00
co-authored by Claude Opus 4.8
parent 8dc163bca6
commit 2c9755f6c8
7 changed files with 202 additions and 50 deletions
+77 -7
View File
@@ -960,6 +960,47 @@ def split_scenes(prompt, placement="start"):
return {"anchor": anchor["raw"].strip(), "anchor_expanded": anchor["exp"].strip(), "scenes": scenes}
def split_scenes_from_segments(anchor, segments, placement="start"):
"""Canonical scene structure from EXPLICIT per-scene boundaries supplied by the caller (the
Movie Editor's own scene list) instead of re-deriving boundaries from in-text delimiters.
The editor already KNOWS where each scene starts (they are separate Scene objects), so it
hands us that list directly — no `scene N` marker ever has to be injected into the prompt.
`anchor` is the shared prefix text; `segments` is one raw text per editor scene (each may
carry its own leading transition trigger, which supplies that scene's effect). Each segment
becomes EXACTLY one scene; its shortcut expansion + refinement keys + effect come from
split_scenes (reused per item, where there is no boundary ambiguity), and the anchor keys
fold into every scene — identical semantics to split_scenes(), same output shape."""
def _flatten(split_out):
# Collapse a split_scenes() result to one scene: any leading anchor text + every
# sub-scene joined; first non-None effect wins; keys unioned.
raw_parts, exp_parts, keys, effect = [], [], set(), None
ar = (split_out.get("anchor") or "").strip()
ae = (split_out.get("anchor_expanded") or "").strip()
if ar:
raw_parts.append(ar)
if ae:
exp_parts.append(ae)
for s in (split_out.get("scenes") or []):
r = (s.get("raw") or "").strip()
e = (s.get("expanded") or "").strip()
if r:
raw_parts.append(r)
if e:
exp_parts.append(e)
keys |= set(s.get("keys") or set())
if effect is None and s.get("effect"):
effect = s.get("effect")
return " ".join(raw_parts).strip(), " ".join(exp_parts).strip(), keys, effect
a_raw, a_exp, anchor_keys, _ = _flatten(split_scenes(str(anchor or ""), placement=placement))
scenes = []
for i, seg in enumerate(segments or []):
r, e, k, eff = _flatten(split_scenes(str(seg or ""), placement=placement))
scenes.append({"index": i, "raw": r, "expanded": e, "keys": anchor_keys | k, "effect": eff})
return {"anchor": a_raw, "anchor_expanded": a_exp, "scenes": scenes}
def refinement_key_pool_for(prompt):
"""Every non-default refinement key the prompt activates (anchor + all scenes) — the exact
set a session reset wipes. Derived from the one canonical split."""
@@ -11672,7 +11713,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
steer_mode="relative", absolute_strength=0.6,
_seed=None, _seed_source="fresh seed", _scene_seeds=None, _velocity_keys=None,
batch_variants=1, guess_mode=False, guess_direction="up", guess_range=1.0,
guess_freeze_seed=True, movie_editor_scene_ratings=None):
guess_freeze_seed=True, movie_editor_scene_ratings=None, scene_segments=None):
seed = int(_seed) if _seed is not None else random.randint(1, 0xffffffffffffffff)
encode_cache = {}
linked_refinement_key = str(refinement_key_input or "").strip()
@@ -12211,13 +12252,41 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
current_scene_seed_source = str(_seed_source or "fresh seed")
if split_by_transitions:
try:
canonical = split_scenes(_raw_positive_prompt, placement=split_transition_placement)
canon_scenes = [s for s in (canonical.get("scenes", []) or [])
if (s.get("expanded") or "").strip()]
# Prefer EXPLICIT scene boundaries handed in by the caller (the Movie Editor
# passes its own scene list). Falls back to text-splitting the prompt only for
# raw node-pack use where there is no structured scene list. Either way no
# `scene N` delimiter is ever needed in the prompt.
structural = isinstance(scene_segments, dict) and scene_segments.get("scenes") is not None
if structural:
canonical = split_scenes_from_segments(
scene_segments.get("anchor", ""), scene_segments.get("scenes", []),
placement=split_transition_placement)
# The editor's scene list is authoritative on COUNT — one conditioning scene
# per editor scene. Keep every scene, including a text-less i2v scene (it stays
# at its index as an anchor-only conditioning); dropping it would shift anchors.
canon_scenes = list(canonical.get("scenes", []) or [])
else:
canonical = split_scenes(_raw_positive_prompt, placement=split_transition_placement)
canon_scenes = [s for s in (canonical.get("scenes", []) or [])
if (s.get("expanded") or "").strip()]
if len(canon_scenes) >= 1:
segments = [(canonical.get("anchor_expanded", ""), None)] + \
[(s.get("expanded", ""), s.get("effect")) for s in canon_scenes]
split_scene_texts, split_scene_effects = self._v2_transition_scene_texts(segments)
anchor_exp = (canonical.get("anchor_expanded", "") or "").strip()
if structural:
# Fold the anchor into each scene WITHOUT dropping empties (mirrors
# _v2_transition_scene_texts but count-preserving for the structural path).
split_scene_texts, split_scene_effects = [], []
for s in canon_scenes:
seg = (s.get("expanded") or "").strip()
if anchor_exp and seg:
sep = " " if anchor_exp[-1] in ".!?," else ", "
split_scene_texts.append(anchor_exp + sep + seg)
else:
split_scene_texts.append(anchor_exp or seg)
split_scene_effects.append(s.get("effect"))
else:
segments = [(anchor_exp, None)] + \
[(s.get("expanded", ""), s.get("effect")) for s in canon_scenes]
split_scene_texts, split_scene_effects = self._v2_transition_scene_texts(segments)
scene_refinement_keys = [set(s.get("keys") or set()) for s in canon_scenes]
current_scene_seeds = self._v2_scene_seed_values(seed, len(split_scene_texts), _scene_seeds)
current_scene_seed_source = (
@@ -14364,6 +14433,7 @@ class FunPackStudio:
_seed_source=seed_source,
_scene_seeds=scene_seed_memory,
movie_editor_scene_ratings=_me_scene_ratings,
scene_segments=(rf.get("scenes") if isinstance(rf.get("scenes"), dict) else None),
)
# --- Conditioning Adjust ---
+5
View File
@@ -299,6 +299,11 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No
_ss = {}
_rf = _ss.get("refiner") if isinstance(_ss.get("refiner"), dict) else {}
_rf["split_by_transitions"] = True
# Explicit scene boundaries from the editor (no `scene N` markers in the prompt).
# Studio (split_scenes_from_segments) uses this list directly to split scenes.
_seg = params.get("scene_segments")
if isinstance(_seg, dict) and _seg.get("scenes"):
_rf["scenes"] = _seg
# reset_session also lives in studio_settings.refiner — armed per-run by the editor
# (first run after the user clicks "Reset Studio session"); explicit so it's never
# left on from a previous run.
+39 -27
View File
@@ -694,42 +694,54 @@ def _leading_trigger_re():
def build_combined_prompt(project: Project, include_excluded: bool = False,
for_generation: bool = False) -> str:
"""The master prompt = anchor + scene texts.
"""The master prompt = anchor + scene texts, joined VERBATIM.
Display (for_generation=False): a verbatim join — transition triggers/shortcuts stay
in-text exactly as typed, so it round-trips with the global prompt.
Transition triggers/shortcuts stay in-text exactly as typed, so it round-trips with the
global prompt. NOTHING is injected — not even for generation. Scene boundaries for
generation are passed STRUCTURALLY via build_generation_scene_segments() (Studio uses that
list directly), so we never have to smuggle a `scene N` delimiter into the prompt. The
`for_generation` flag is kept for call-site compatibility but no longer changes the output.
"""
scenes = [
s for s in project.scenes
if (include_excluded or not s.excluded) and is_generative_scene(s)
]
parts: list[str] = []
anchor = effective_anchor(project)
if anchor:
parts.append(anchor)
for _uid, group in group_generative_units(scenes):
text = (gen_unit_root(group).text or "").strip()
if text:
parts.append(text)
return " ".join(p for p in parts if p).strip()
Generation (for_generation=True): Studio must split into EXACTLY one scene per timeline
scene, but a `carry` scene's text has no leading transition, so Studio would merge it
with the previous one (only the first scene gets generated). So before any scene whose
text doesn't already begin with a transition trigger we inject a separator (the seam's
transition_to_next, else the generic 'scene N' label) to force the split. This affects
only what's sent to Studio, never the displayed global prompt.
def build_generation_scene_segments(project: Project, include_excluded: bool = False) -> dict:
"""The run's scene boundaries, handed to Studio structurally so no `scene N` marker is ever
injected into the prompt. The editor already knows every boundary (each generative unit is a
scene), so we just list them.
Returns {"anchor": <shared prefix text>, "scenes": [<raw unit text>, ...]} where each unit's
text is prefixed with its REAL leading transition (intro_transition for the first unit, the
previous unit's transition_to_next otherwise) when one exists — so Studio's per-scene split
still picks up the transition's visual effect. A `carry` unit with no transition is just its
text (effect = none). Studio (split_scenes_from_segments) expands shortcuts + extracts keys
per item; one unit = exactly one scene.
"""
scenes = [
s for s in project.scenes
if (include_excluded or not s.excluded) and is_generative_scene(s)
]
units = group_generative_units(scenes)
parts: list[str] = []
anchor = effective_anchor(project)
if anchor:
parts.append(anchor)
# Only inject separators for a MULTI-unit run: editorial subclips count as one unit.
inject = for_generation and len(units) > 1
trig_re = _leading_trigger_re() if inject else None
out: list[str] = []
for i, (_uid, group) in enumerate(units):
root = gen_unit_root(group)
text = (root.text or "").strip()
if inject and not (text and trig_re and trig_re.match(text)):
prev_root = gen_unit_root(units[i - 1][1]) if i > 0 else None
marker = (project.intro_transition or "").strip() if i == 0 else ((prev_root.transition_to_next if prev_root else "") or "").strip()
if not marker:
marker = f"scene {i + 1}"
parts.append(marker)
if text:
parts.append(text)
return " ".join(p for p in parts if p).strip()
text = (gen_unit_root(group).text or "").strip()
prev_root = gen_unit_root(units[i - 1][1]) if i > 0 else None
trans = ((project.intro_transition or "").strip() if i == 0
else ((prev_root.transition_to_next if prev_root else "") or "").strip())
out.append((trans + " " + text).strip() if trans else text)
return {"anchor": effective_anchor(project), "scenes": out}
def _scene_run_fingerprint(sc: Scene) -> str:
+4
View File
@@ -25,6 +25,7 @@ from .backend.nle_overlays import build_overlay_video_filter, prepare_overlay_ex
from .backend.timeline import (
Project,
build_combined_prompt,
build_generation_scene_segments,
collapse_generative_units,
continuity_media_refs,
effective_negative_prompt,
@@ -1507,6 +1508,9 @@ if web is not None and PromptServer is not None:
"studio_inputs": _run_studio_inputs(
target, active_scenes, prompt_changed=prompt_changed, models=models_cfg,
),
# Scene boundaries handed to Studio structurally (the editor knows them) so the
# generation prompt stays clean — no injected `scene N` delimiters.
"scene_segments": build_generation_scene_segments(target),
"sampler_inputs": sampler_inputs,
"reset_session": reset_session,
"refinement_key": (target.refinement_key or "default"),
+21 -1
View File
@@ -3,7 +3,9 @@
from pathlib import Path
from movie_editor.backend import bridge
from movie_editor.backend.timeline import Project, Scene
from movie_editor.backend.timeline import (
Project, Scene, build_combined_prompt, build_generation_scene_segments,
)
from movie_editor.server import (
_build_render_filter,
_clip_bytes_for_media,
@@ -185,6 +187,24 @@ def test_studio_inputs_skips_custom_conditioning():
assert _run_studio_inputs(p, p.scenes) == {}
def test_generation_prompt_has_no_injected_scene_markers():
# The generation prompt must be the verbatim text the user wrote — never an injected
# 'scene N' delimiter. Boundaries travel structurally via build_generation_scene_segments.
p = _project(scenes=[
{"id": "s1", "text": "a red car drives down a street", "source": {"type": "image", "media_ref": "img1"}},
{"id": "s2", "text": "the car stops at a cafe", "source": {"type": "carry"}},
{"id": "s3", "text": "a woman exits the cafe", "source": {"type": "carry"}},
])
gen = build_combined_prompt(p, for_generation=True)
assert "scene 1" not in gen.lower()
assert "scene 2" not in gen.lower()
assert gen == "a red car drives down a street the car stops at a cafe a woman exits the cafe"
# boundaries are carried structurally instead — one entry per generative scene
seg = build_generation_scene_segments(p)
assert seg["scenes"] == [
"a red car drives down a street", "the car stops at a cafe", "a woman exits the cafe"]
def test_score_slider_sampler_inputs_pass_through():
# FreeSliders score_slider is a plain sampler knob (like embed_guidance): the editor
# sets it in sampler_inputs and _run_sampler_inputs must carry it through untouched
+20 -15
View File
@@ -16,6 +16,7 @@ from movie_editor.backend.timeline import ( # noqa: E402
Scene,
STUDIO_DEFAULT_GUIDE,
build_combined_prompt,
build_generation_scene_segments,
build_auto_continuity_guides,
build_mixed_solo_guides_payload,
build_scene_anchors_payload,
@@ -40,7 +41,7 @@ def _project(**kw):
return p
def test_anchor_plus_two_scenes_emits_separators():
def test_generation_prompt_is_verbatim_no_injected_separators():
p = _project(
anchor="red-haired heroine",
intro_transition="cut to",
@@ -50,15 +51,16 @@ def test_anchor_plus_two_scenes_emits_separators():
],
)
prompt = build_combined_prompt(p, for_generation=True)
# anchor first, intro marker before scene 0, scene marker before scene 1
assert prompt.startswith("red-haired heroine")
assert "cut to" in prompt
assert "blur" in prompt
assert prompt.index("cut to") < prompt.index("flying")
assert prompt.index("blur") < prompt.index("landing")
# anchor + scene texts, VERBATIM — nothing injected.
assert prompt == "red-haired heroine flying over the city landing on a rooftop"
assert "scene 1" not in prompt and "scene 2" not in prompt
# boundaries + the real transitions travel STRUCTURALLY instead of in the prompt
seg = build_generation_scene_segments(p)
assert seg["anchor"] == "red-haired heroine"
assert seg["scenes"] == ["cut to flying over the city", "blur landing on a rooftop"]
def test_generation_prompt_fingerprint_includes_split_markers():
def test_generation_prompt_fingerprint_has_no_split_markers():
p = _project(
anchor="red-haired heroine",
intro_transition="cut to",
@@ -68,9 +70,9 @@ def test_generation_prompt_fingerprint_includes_split_markers():
],
)
fp = generation_prompt_fingerprint(p, p)
assert "cut to" in fp["generation_prompt"]
assert "blur" in fp["generation_prompt"]
assert fp["display_prompt"] != fp["generation_prompt"]
# generation prompt is now verbatim (boundaries are structural) — no injected separators
assert "scene 1" not in fp["generation_prompt"]
assert fp["generation_prompt"] == "red-haired heroine flying over the city landing on a rooftop"
def test_generation_prompt_hash_changes_when_text_changes():
@@ -90,15 +92,18 @@ def test_generation_run_hash_changes_when_anchor_changes():
assert r1["run_hash"] != r2["run_hash"]
def test_missing_markers_fall_back_to_scene_labels():
def test_carry_scenes_have_no_injected_labels():
p = _project(
anchor="a knight",
scenes=[{"text": "in a forest"}, {"text": "at a castle"}],
)
prompt = build_combined_prompt(p, for_generation=True)
# No explicit markers -> generic "scene N" boundaries so the split still separates.
assert "scene 1" in prompt
assert "scene 2" in prompt
# carry scenes (no transition) used to get an injected 'scene N' label — no longer.
assert "scene 1" not in prompt and "scene 2" not in prompt
assert prompt == "a knight in a forest at a castle"
# the two boundaries are still there, structurally
seg = build_generation_scene_segments(p)
assert seg["scenes"] == ["in a forest", "at a castle"]
def test_video_clips_excluded_from_combined_prompt():
+36
View File
@@ -288,6 +288,42 @@ def test_generic_scene_label_splits_but_is_never_encoded(monkeypatch):
assert "cut" in real["scenes"][0]["expanded"]
def test_split_scenes_from_segments_uses_explicit_boundaries(monkeypatch):
"""The editor hands Studio its OWN scene list, so boundaries never need an in-text delimiter.
One segment = exactly one scene; anchor keys fold into every scene; no 'scene N' anywhere."""
monkeypatch.setattr(templates, "load_shortcut_db", lambda: _db({}))
monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {})
out = conditioning.split_scenes_from_segments(
"", ["a red car drives down a street", "the car stops at a cafe", "a woman exits"])
assert [s["expanded"] for s in out["scenes"]] == [
"a red car drives down a street", "the car stops at a cafe", "a woman exits"]
for s in out["scenes"]:
assert "scene 1" not in s["expanded"].lower() and "scene 2" not in s["expanded"].lower()
def test_split_scenes_from_segments_preserves_empty_scene(monkeypatch):
"""A text-less i2v scene must KEEP its slot (count = editor scene count), not be dropped —
otherwise per-scene anchors/seeds shift by one. It becomes an empty (anchor-only) scene."""
monkeypatch.setattr(templates, "load_shortcut_db", lambda: _db({}))
monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {})
out = conditioning.split_scenes_from_segments("", ["", "a red car", "the cafe"])
assert len(out["scenes"]) == 3
assert out["scenes"][0]["expanded"] == ""
assert [s["expanded"] for s in out["scenes"][1:]] == ["a red car", "the cafe"]
def test_split_scenes_from_segments_folds_anchor_and_keys(monkeypatch):
"""Anchor text + anchor keys fold into every scene, exactly like split_scenes()."""
db = _db({"anc": (["zeta"], ["ZZZ"], "anchorKey"), "a": (["alpha"], ["AAA"], "keyA")})
monkeypatch.setattr(templates, "load_shortcut_db", lambda: db)
monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {})
out = conditioning.split_scenes_from_segments("zeta", ["alpha here", "plain scene"])
assert out["anchor_expanded"] == "ZZZ"
assert out["scenes"][0]["keys"] == {"anchorKey", "keyA"}
assert out["scenes"][1]["keys"] == {"anchorKey"}
assert "AAA" in out["scenes"][0]["expanded"]
def test_split_scenes_single_scene(monkeypatch):
db = _db({"hero": (["hero"], ["a knight"], "mykey")})
monkeypatch.setattr(templates, "load_shortcut_db", lambda: db)