diff --git a/conditioning.py b/conditioning.py index 7d854c17..55b8ee63 100644 --- a/conditioning.py +++ b/conditioning.py @@ -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 --- diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index e4162e90..37653565 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -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. diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 2731dceb..1c069a28 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -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": , "scenes": [, ...]} 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: diff --git a/movie_editor/server.py b/movie_editor/server.py index c43a6f46..b1de3551 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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"), diff --git a/movie_editor/tests/test_server.py b/movie_editor/tests/test_server.py index 68841dce..838e4923 100644 --- a/movie_editor/tests/test_server.py +++ b/movie_editor/tests/test_server.py @@ -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 diff --git a/movie_editor/tests/test_timeline.py b/movie_editor/tests/test_timeline.py index 9d8f5fc3..e7323f40 100644 --- a/movie_editor/tests/test_timeline.py +++ b/movie_editor/tests/test_timeline.py @@ -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(): diff --git a/tests/test_refinement_keys.py b/tests/test_refinement_keys.py index c16c4287..3407ec49 100644 --- a/tests/test_refinement_keys.py +++ b/tests/test_refinement_keys.py @@ -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)