diff --git a/conditioning.py b/conditioning.py index 8bbc7fc1..b7a20078 100644 --- a/conditioning.py +++ b/conditioning.py @@ -9920,6 +9920,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): prompt_enhance_temperature=0.7, prompt_enhance_top_p=0.92, prompt_enhance_max_length=400, prompt_enhance_thinking=False, prompt_enhance_image=None, prompt_enhance_sampling=None, + prompt_enhance_output=False, _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, scene_segments=None, @@ -10591,6 +10592,26 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): "scene": None, "before": _before, "after": prompt_to_encode, "status": _enhance_status, "image": prompt_enhance_image is not None}) + # Studio's `enhanced_prompt` output, for a node encoding on its own (Editor link + # "Enhanced prompt, fallback to prompt + postfix"). The same text that link's + # fallback sends — expanded prompt + postfix — enhanced once as a whole. + self._v2_enhanced_output = None + if prompt_enhance_output: + _whole = str((_link_texts or {}).get("full_prompt") or "").strip() + if prompt_enhance and _whole: + _after, _st = self._v2_enhance_prompt( + _enhance_clip, _whole, _enhance_system, cache=_enhance_cache, + seed=_enhance_seed, temperature=prompt_enhance_temperature, + top_p=prompt_enhance_top_p, max_length=prompt_enhance_max_length, + thinking=prompt_enhance_thinking, image=prompt_enhance_image, + **_enhance_sampling) + print(f"[FunPackVideoRefinerV2] Enhanced prompt output: {_st}") + self._v2_enhanced_prompts.append({ + "scene": None, "before": _whole, "after": _after, "status": _st, + "image": prompt_enhance_image is not None}) + _whole = _after + self._v2_enhanced_output = _whole + cond, meta, encode_status, conditioning_owner = self._v2_conditioning_source( clip, prompt_to_encode, @@ -13517,8 +13538,8 @@ class FunPackStudio: Advisor LLM, LoRA management, and Conditioning Adjust under one UI.""" CATEGORY = "FunPack" - RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "INT", "SAMPLER", "SIGMAS", "SAMPLER", "SIGMAS", "IMAGE", "STRING", "STRING", "STRING", "LATENT") - RETURN_NAMES = ("model", "modified_positive", "negative", "seed", "high_pass_sampler", "high_pass_sigmas", "low_pass_sampler", "low_pass_sigmas", "loss_graph", "status", "training_info", "encoded_prompts", "video_latent") + RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "INT", "SAMPLER", "SIGMAS", "SAMPLER", "SIGMAS", "IMAGE", "STRING", "STRING", "STRING", "LATENT", "STRING") + RETURN_NAMES = ("model", "modified_positive", "negative", "seed", "high_pass_sampler", "high_pass_sigmas", "low_pass_sampler", "low_pass_sigmas", "loss_graph", "status", "training_info", "encoded_prompts", "video_latent", "enhanced_prompt") FUNCTION = "run" DESCRIPTION = ( "FunPack Studio - all FunPack refinement tools in one node. " @@ -13823,6 +13844,7 @@ class FunPackStudio: h3_phrase_emphasis=bool(rf.get("h3_phrase_emphasis", False)), h3_phrase_variability=float(rf.get("h3_phrase_variability", 0.0) or 0.0), prompt_enhance=bool(rf.get("prompt_enhance", False)), + prompt_enhance_output=bool(rf.get("prompt_enhance_output", False)), prompt_enhance_system=str(rf.get("prompt_enhance_system", "") or ""), prompt_enhance_temperature=float(rf.get("prompt_enhance_temperature", 0.7)), prompt_enhance_top_p=float(rf.get("prompt_enhance_top_p", 0.92)), @@ -13928,7 +13950,11 @@ class FunPackStudio: cond = _h3_reconcile_token_tags(cond, "positive") out_negative = _h3_reconcile_token_tags(out_negative, "negative") - result = (out_model, cond, out_negative, seed, high_sampler, high_sigmas, low_sampler, low_sigmas, loss_graph, status, training_info, encoded_prompts, video_latent) + # Enhanced prompt + postfix, or the plain one when nothing enhanced it. + _whole = getattr(refiner, "_v2_enhanced_output", None) + if _whole is None: + _whole = str((rf.get("link_texts") or {}).get("full_prompt") or "") + result = (out_model, cond, out_negative, seed, high_sampler, high_sigmas, low_sampler, low_sigmas, loss_graph, status, training_info, encoded_prompts, video_latent, _whole) enhanced = getattr(refiner, "_v2_enhanced_prompts", None) if enhanced: # History carries every node's ui output; the Editor's /status reads this key. diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index 25c5097f..ed0b1273 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -51,6 +51,9 @@ CORE: dict[str, str] = { # core internal links: core_id -> {input_name: (src_core_id, output_index)} +# FunPackStudio's `enhanced_prompt` output (last in RETURN_TYPES). +STUDIO_ENHANCED_PROMPT_OUT = 13 + CORE_LINKS: dict[str, dict[str, tuple[str, int]]] = { "studio": {"positive_prompt": ("pos", 0), "negative_prompt": ("neg", 0), "refinement_key_input": ("keyloader", 0)}, @@ -820,6 +823,27 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No # linked inputs — they still receive the raw text and expand it themselves. expanded = params.get("expanded") or {} val = expanded[key] if key in expanded else params.get(key) + # Enhanced prompt: written by Studio's enhancer DURING the run, so it is a link to + # Studio's `enhanced_prompt` output, not a value. Studio sends prompt + postfix + # through unchanged when the enhancer is off or fails. + if key == "enhanced_prompt": + if "studio" in graph: + val = ["studio", STUDIO_ENHANCED_PROMPT_OUT] + try: + _ss_l = json.loads(str(graph["studio"]["inputs"].get("studio_settings") or "{}")) + except ValueError: + _ss_l = {} + if not isinstance(_ss_l, dict): + _ss_l = {} + _rf_l = _ss_l.get("refiner") if isinstance(_ss_l.get("refiner"), dict) else {} + _rf_l["prompt_enhance_output"] = True + _ss_l["refiner"] = _rf_l + graph["studio"]["inputs"]["studio_settings"] = json.dumps(_ss_l) + else: + val = expanded.get("full_prompt") + report["ignored"].append( + f"Linked input '{link.get('name') or key}': the built-in pipeline is off, " + f"so there is no Studio to enhance — sent prompt + postfix instead.") # A latent node driven by "Project · Frames" has to receive the SAME number the # sampler is given, or the two disagree about the scene length and the run dies # on the mismatch. Same for a fixed-rate family's fps. @@ -858,10 +882,13 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No f"Linked input '{label}': {graph[sid].get('class_type')} has no widget called " f"'{inp}' — nothing was set. Re-pick it in Models ▸ Linked inputs.") continue - graph[sid]["inputs"][inp] = val + graph[sid]["inputs"][inp] = list(val) if isinstance(val, list) else val + if isinstance(val, list): + protected_edges.add((sid, inp)) applied.append(f"{graph[sid].get('class_type')}.{inp}") if applied: - report["wired"].append(f"linked '{label}' = {val} -> " + ", ".join(applied)) + shown = "Studio · enhanced_prompt" if isinstance(val, list) else val + report["wired"].append(f"linked '{label}' = {shown} -> " + ", ".join(applied)) port_to_core = _port_index(object_info, core=CORE) diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index dfc1a7f3..6aed7285 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -772,7 +772,8 @@ const tag = el("div", "insp-tag"); tag.textContent = "Exposed controls"; wrap.append(tag); const SRC_LBL = { frame_rate: "Project FPS", num_frames_per_scene: "Project Frames", width: "Project Width", height: "Project Height", prompt: "Project Prompt", - negative_prompt: "Project Negative prompt", seed: "Project Seed" }; + negative_prompt: "Project Negative prompt", seed: "Project Seed", + full_prompt: "Project Prompt + postfix", enhanced_prompt: "Studio Enhanced prompt" }; linkItems.forEach((l) => { if (l.source === "editor") { const note = el("div", "lib-sub"); note.textContent = `← ${SRC_LBL[l.editor_key] || l.editor_key}`; diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index 47e40fc6..ce500c18 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -1941,6 +1941,9 @@ { key: "anchor", label: "Project · Anchor text (prepended)", kinds: ["string"] }, { key: "postfix", label: "Project · Postfix (appended)", kinds: ["string"] }, { key: "full_prompt", label: "Project · Prompt + postfix (what Studio encodes)", kinds: ["string"] }, + // Written by Studio's enhancer during the run, so the builder wires it to Studio's + // enhanced_prompt output rather than pasting a value. Enhancer off → prompt + postfix. + { key: "enhanced_prompt", label: "Studio · Enhanced prompt (else prompt + postfix)", kinds: ["string"] }, { key: "seed", label: "Project · Seed", kinds: ["int", "float"] }, { key: "frame_rate", label: "Project · FPS", kinds: ["int", "float"] }, { key: "num_frames_per_scene", label: "Project · Frames", kinds: ["int", "float"] }, @@ -1995,7 +1998,7 @@ if (link.source === "editor") { const srcLbl = (EDITOR_SOURCES.find((s) => s.key === link.editor_key) || {}).label || link.editor_key; - const isText = ["prompt", "negative_prompt", "anchor", "postfix", "full_prompt"].includes(link.editor_key); + const isText = ["prompt", "negative_prompt", "anchor", "postfix", "full_prompt", "enhanced_prompt"].includes(link.editor_key); // "the same text Studio would" is only true of full_prompt. Studio appends the postfix // to every scene itself, so a node linked to the global prompt encodes strictly less -- // silently, and the postfix is where audio and style directions usually live. @@ -2004,6 +2007,9 @@ note = " Shortcuts and $variables are expanded first"; if (link.editor_key === "full_prompt") { note += ", so the node encodes the same text Studio would."; + } else if (link.editor_key === "enhanced_prompt") { + note = " Prompt + postfix, rewritten by the enhancer (Composer ▸ Enhance). Enhancer off" + + " or failed: prompt + postfix unchanged."; } else if (link.editor_key === "prompt") { const pj = window.Store?.get().project || {}; const hasPostfix = pj.postfix_enabled !== false && String(pj.postfix || "").trim(); diff --git a/movie_editor/tests/test_builder.py b/movie_editor/tests/test_builder.py index 1c782f1b..27d92031 100644 --- a/movie_editor/tests/test_builder.py +++ b/movie_editor/tests/test_builder.py @@ -1580,3 +1580,20 @@ def test_studio_receives_the_texts_linked_nodes_were_given(): settings = json.loads(graph["studio"]["inputs"]["studio_settings"]) assert settings["refiner"]["link_texts"]["prompt"] == "cinematic rain" assert settings["refiner"]["link_texts"]["full_prompt"] == "cinematic rain now" + + +def test_enhanced_prompt_link_wires_to_studios_output(): + import json + oi = {**OI, "R2V": {"input": {"required": {"prompt": ["STRING", {"default": ""}]}}, + "output": ["CONDITIONING"], "output_name": ["CONDITIONING"]}} + oi["FunPackStudio"] = {**OI["FunPackStudio"], + "output": OI["FunPackStudio"]["output"] + ["STRING"], + "output_name": OI["FunPackStudio"]["output_name"] + ["enhanced_prompt"]} + models = {"slots": [{"id": "r2v", "node_class": "R2V", "inputs": {}, "wires": {}}], + "links": [{"id": "l", "name": "p", "source": "editor", "editor_key": "enhanced_prompt", + "members": [{"slotId": "r2v", "input": "prompt"}]}]} + graph, report = builder.build(oi, models, {**PARAMS, "expanded": {"full_prompt": "a cat, soft"}}) + assert graph["slot_r2v"]["inputs"]["prompt"] == ["studio", builder.STUDIO_ENHANCED_PROMPT_OUT] + assert oi["FunPackStudio"]["output_name"][builder.STUDIO_ENHANCED_PROMPT_OUT] == "enhanced_prompt" + rf = json.loads(graph["studio"]["inputs"]["studio_settings"])["refiner"] + assert rf["prompt_enhance_output"] is True diff --git a/tests/test_prompt_enhancer.py b/tests/test_prompt_enhancer.py index 3ac8667b..3e8b8832 100644 --- a/tests/test_prompt_enhancer.py +++ b/tests/test_prompt_enhancer.py @@ -395,7 +395,7 @@ def test_the_image_reaches_the_enhancer(): def test_both_paths_hand_over_the_enhancer_image(): src = _refine_source() - assert src.count("image=prompt_enhance_image") == 2 + assert src.count("image=prompt_enhance_image") == 3 assert "thinking=prompt_enhance_thinking, image=source_image" not in src @@ -409,7 +409,7 @@ def test_studio_gates_the_image_on_its_own_switch(): def test_every_rewrite_is_reported_for_the_composer(): src = _refine_source() - assert src.count("self._v2_enhanced_prompts.append(") == 2 + assert src.count("self._v2_enhanced_prompts.append(") == 3 import inspect assert '{"ui": {"funpack_enhanced": enhanced}, "result": result}' in inspect.getsource(C.FunPackStudio.run) @@ -437,7 +437,7 @@ def test_the_repair_advisor_keeps_its_old_sampling(studio): def test_a_fixed_enhancer_seed_overrides_the_run_seed(): src = _refine_source() assert '_enhance_seed = int(_enhance_sampling.pop("seed", 0) or 0) or seed' in src - assert src.count("seed=_enhance_seed") == 2 + assert src.count("seed=_enhance_seed") == 3 # ── how the instructions reach the model ───────────────────────────────────── diff --git a/tests/test_v2_category_alignment.py b/tests/test_v2_category_alignment.py index 9c3c5f37..48df95f3 100644 --- a/tests/test_v2_category_alignment.py +++ b/tests/test_v2_category_alignment.py @@ -2295,3 +2295,29 @@ def test_h3_scale_digits_are_valid_combo_choices(): whatever the default happens to be.""" for n in range(1, 11): assert str(n) in V2_RATING_LABELS + + +def _enhanced_output(tmp_path, **kw): + refiner = FunPackVideoRefinerV2() + refiner._v2_state_path = lambda refinement_key: str(tmp_path / "state.json") + clip = GeneratingClip("a vivid cat on a sunlit sill") + refiner.refine_v2("a cat", clip, "Unrated", "enh-out-test", + link_texts={"full_prompt": "a cat, soft light", "prompt": "a cat"}, **kw) + return refiner._v2_enhanced_output, clip + + +def test_enhanced_prompt_output_enhances_prompt_plus_postfix(tmp_path): + out, clip = _enhanced_output(tmp_path, prompt_enhance=True, prompt_enhance_output=True) + assert out == "a vivid cat on a sunlit sill" + assert any(t.endswith("a cat, soft light") for t, _ in clip.tokenize_calls) + + +def test_enhanced_prompt_output_falls_back_to_prompt_plus_postfix(tmp_path): + out, clip = _enhanced_output(tmp_path, prompt_enhance=False, prompt_enhance_output=True) + assert out == "a cat, soft light" and clip.generate_kwargs == {} + + +def test_enhanced_prompt_output_costs_nothing_when_unlinked(tmp_path): + out, clip = _enhanced_output(tmp_path, prompt_enhance=True) + assert out is None + assert not any(t.endswith("a cat, soft light") for t, _ in clip.tokenize_calls)