From 409f9cf58d42307a7615bed4fe37327152f1ea8d Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:47:21 +0300 Subject: [PATCH] fix(h3): place the emphasis from the text that was actually encoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skipping was not the answer — Studio has the text. What it did not have was a way to know WHICH text: it keeps the raw prompt on its own port and expands per scene, while the editor hands a linked node the already expanded string. So its copy and the encoded one can differ. server._expanded_link_texts is exactly what a node outside Studio received, and the builder now forwards it. And the choice is CHECKED, not guessed: minimax_token_tags says how many text positions the tensor holds, so each candidate is tokenized and the one that fits wins, which also yields the exact base offset. No arithmetic fallback, no assumption about whose string it was. Emphasis is only skipped when no candidate tokenizes to the tensor's text run at all, which means the encoded string genuinely is not here. --- conditioning.py | 53 +++++++++++++++++++++--------- h3_token_weights.py | 42 +++++++++++++++++++++++ movie_editor/backend/builder.py | 7 ++++ movie_editor/tests/test_builder.py | 12 +++++++ tests/test_h3_token_weights.py | 47 ++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 16 deletions(-) diff --git a/conditioning.py b/conditioning.py index 31ec612a..32ad5fdc 100644 --- a/conditioning.py +++ b/conditioning.py @@ -12172,7 +12172,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): _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, - variables=None, h3_references=None): + variables=None, h3_references=None, link_texts=None): seed = int(_seed) if _seed is not None else random.randint(1, 0xffffffffffffffff) # Project-scoped `$name` variables, resolved DEAD LAST (after shortcut-expand + split) # so they can never affect scene-cut detection. Empty/None = no-op. @@ -12181,6 +12181,11 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): except ImportError: from templates import resolve_variables as _resolve_variables _prompt_variables = variables or None + # Exactly what the editor handed to nodes OUTSIDE Studio (server._expanded_link_texts): + # shortcuts rolled and $variables resolved. When a wired conditioning owns the prompt, + # one of these IS the string that was encoded, so the emphasis can be placed on the + # real tokens instead of on Studio's own copy of a prompt it did not encode. + _link_texts = link_texts if isinstance(link_texts, dict) else None # MiniMax H3 ref2va reference media, in the order the user listed it. No other model # family has the tokenizer kwarg it feeds, so it is inert when unset or on LTX. # minimax_h3.resolve_ref_spec explains why the ORDER is load-bearing. @@ -13230,7 +13235,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): guess_mode=guess_mode, guess_direction=guess_direction, guess_range=guess_range, guess_freeze_seed=guess_freeze_seed) return ( - self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength, spread_cap=_guess_spread_cap, temporal_style=temporal_style, temporal_fallback_text=prompt_to_encode, scene_refinement_keys=scene_refinement_keys, learning_profile=learning_profile, conditioning_plan=_conditioning_plan, clip=clip, encode_cache=encode_cache, phrase_memory=global_state.get("phrase_memory"), axis_feedback=repair_feedback, h3_phrase_emphasis=h3_phrase_emphasis, auto_strength=self._v2_auto_strength(global_state), variables=_prompt_variables), + self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength, spread_cap=_guess_spread_cap, temporal_style=temporal_style, temporal_fallback_text=prompt_to_encode, scene_refinement_keys=scene_refinement_keys, learning_profile=learning_profile, conditioning_plan=_conditioning_plan, clip=clip, encode_cache=encode_cache, phrase_memory=global_state.get("phrase_memory"), axis_feedback=repair_feedback, h3_phrase_emphasis=h3_phrase_emphasis, auto_strength=self._v2_auto_strength(global_state), variables=_prompt_variables, link_texts=_link_texts), status, training_info, loss_graph, @@ -13248,7 +13253,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): repair_feedback, current_family_slot, _vf_for_memory, _concept_dir, _concept_strength, _current_final) return ( - self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength, spread_cap=_guess_spread_cap, temporal_style=temporal_style, temporal_fallback_text=prompt_to_encode, scene_refinement_keys=scene_refinement_keys, learning_profile=learning_profile, conditioning_plan=_conditioning_plan, clip=clip, encode_cache=encode_cache, phrase_memory=global_state.get("phrase_memory"), axis_feedback=repair_feedback, h3_phrase_emphasis=h3_phrase_emphasis, auto_strength=self._v2_auto_strength(global_state), variables=_prompt_variables), + self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength, spread_cap=_guess_spread_cap, temporal_style=temporal_style, temporal_fallback_text=prompt_to_encode, scene_refinement_keys=scene_refinement_keys, learning_profile=learning_profile, conditioning_plan=_conditioning_plan, clip=clip, encode_cache=encode_cache, phrase_memory=global_state.get("phrase_memory"), axis_feedback=repair_feedback, h3_phrase_emphasis=h3_phrase_emphasis, auto_strength=self._v2_auto_strength(global_state), variables=_prompt_variables, link_texts=_link_texts), status + enhancement_status, training_info, loss_graph, @@ -13935,7 +13940,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): def _v2_apply_h3_token_weights(self, conditioning_list, clip, phrase_memory=None, axis_feedback=None, fallback_text="", enabled=False, - auto_strength=None, variables=None): + auto_strength=None, variables=None, link_texts=None): """Tag each scene with the token spans the RATING says deserve more attention. Studio has decomposed and scored every phrase, word and n-gram since 2.0 @@ -14025,9 +14030,15 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): # and $variables resolved. funpack_scene_text is the RAW one, and tokenizing that # measures a different string from the one inside the conditioning: every span # lands on the wrong words, or the placement guard rejects the run outright. - text = (str(meta.get("funpack_encode_text") or "").strip() - or str(meta.get("funpack_scene_text") or "").strip() - or str(fallback_text or "")) + # Candidates for "the string this tensor was encoded from", best first. On a + # wired conditioning that string came from the editor's expansion for linked + # nodes, not from Studio's own copy — which is why this does not simply trust + # one. `choose_encoded_text` CHECKS each against the tensor's own text-tag run + # and takes the one that fits, so the placement is verified rather than assumed. + candidates = [meta.get("funpack_encode_text"), meta.get("funpack_scene_text"), + fallback_text] + if isinstance(link_texts, dict): + candidates += [link_texts.get(k) for k in ("full_prompt", "prompt")] try: from . import h3_token_weights as _tw except ImportError: @@ -14035,16 +14046,24 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): # Capped AFTER ranking by weight, so the eight that survive are the eight the # rating cared most about; ordered longest-first only for application, so a # phrase's bias lands before one of its own words adds to it. + cond_len = int(cond.shape[1]) if hasattr(cond, "shape") and cond.dim() >= 2 else 0 + text, _n, verified_base = _tw.choose_encoded_text( + tokenizer, candidates, meta.get("minimax_token_tags"), cond_len) + if text is None: + # No candidate matches the text the tensor holds, so nothing here knows where + # the prompt sits. Biasing by arithmetic would land on the wrong words. + text = str(next((c for c in candidates if str(c or "").strip()), "")) + verified_base = None chosen = _tw.order_for_application(weighted[:8]) spans, prompt_tokens = _tw.locate(tokenizer, text, chosen) if spans and prompt_tokens: entry_meta = {"spans": spans, "prompt_tokens": int(prompt_tokens)} # Where the prompt SITS is read from the modality tags when they are here, # rather than assumed to be the tail of the conditioning. - base = _tw.prompt_base(meta.get("minimax_token_tags"), - int(cond.shape[1]) if hasattr(cond, "shape") and cond.dim() >= 2 else 0, - int(prompt_tokens)) + base = verified_base if verified_base is not None else _tw.prompt_base( + meta.get("minimax_token_tags"), cond_len, int(prompt_tokens)) if base is None and meta.get("funpack_conditioning_owner") == "wired": + # Only reachable when NO candidate matched the tensor's text run. # The fallback places the prompt by arithmetic — cond_len minus the # tokens measured HERE. That only holds when Studio encoded the text. On # a wired conditioning another node did, from a string this one never @@ -14053,10 +14072,10 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): # them the honest result is no emphasis. _log.note_on_change( "h3:emphasis_placement", "FunPackStudio", - "phrase emphasis is SKIPPED for the wired positive CONDITIONING: the " - "modality tags do not agree with the prompt measured here, and this " - "node did not encode that text, so there is no way to place the bias " - "on the right tokens.") + "phrase emphasis is SKIPPED for the wired positive CONDITIONING: " + "none of the prompt texts this node holds tokenizes to the number of " + "text positions the tensor actually has, so the string it was encoded " + "from is not among them and the bias cannot be placed.") out.append([cond, meta] if isinstance(entry, list) else (cond, meta)) continue if base is not None: @@ -14081,7 +14100,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): conditioning_plan=None, clip=None, encode_cache=None, phrase_memory=None, axis_feedback=None, h3_phrase_emphasis=False, auto_strength=None, - variables=None): + variables=None, link_texts=None): """Single output hook for both steering modes. Relative = per-key VF ascend (current behaviour). Absolute = global taste pull. Both = layer them. Finally, if Interactive Guessing has learned a safe-spread ceiling, clamp the output conditioning's video-channel @@ -14133,7 +14152,8 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): fallback_text=temporal_fallback_text, enabled=h3_phrase_emphasis, auto_strength=auto_strength, - variables=variables) + variables=variables, + link_texts=link_texts) if mode in ("relative", "both"): out = _step("relative steering", out, lambda c: self._v2_apply_scene_refinement_keys( c, scene_refinement_keys, refinement_key, @@ -15423,6 +15443,7 @@ class FunPackStudio: movie_editor_scene_ratings=_me_scene_ratings, scene_segments=(rf.get("scenes") if isinstance(rf.get("scenes"), dict) else None), variables=(rf.get("variables") if isinstance(rf.get("variables"), (list, dict)) else None), + link_texts=(rf.get("link_texts") if isinstance(rf.get("link_texts"), dict) else None), h3_references=rf.get("h3_references"), ) diff --git a/h3_token_weights.py b/h3_token_weights.py index 7506e1ba..e698ab6b 100644 --- a/h3_token_weights.py +++ b/h3_token_weights.py @@ -397,6 +397,48 @@ def _as_list(tags): return list(tags) if isinstance(tags, (list, tuple)) else [] +def choose_encoded_text(tokenizer, candidates, token_tags, cond_len): + """Pick the candidate string whose token count matches the conditioning's text run. + + A wired conditioning was encoded by ANOTHER node, from a string the editor expanded for + it — Studio keeps the raw prompt and expands per scene, so its copy can differ. Guessing + which is not necessary: `minimax_token_tags` says how many text positions the tensor + actually holds, so each candidate can be CHECKED against it and the one that fits wins. + + Returns (text, token_count, base) or (None, 0, None) when nothing fits. + """ + tags = _as_list(token_tags) + if not tags: + return None, 0, None + end = min(len(tags), cond_len) + run_end = end + while run_end > 0 and int(tags[run_end - 1]) != 1: + run_end -= 1 + run_start = run_end + while run_start > 0 and int(tags[run_start - 1]) == 1: + run_start -= 1 + run = run_end - run_start + if run <= 0: + return None, 0, None + best = None + for text in candidates: + text = str(text or "").strip() + if not text: + continue + try: + n = len(tokenizer(text, add_special_tokens=False, + return_offsets_mapping=True)["offset_mapping"]) + except Exception: # noqa: BLE001 + continue + gap = run - n + if 0 <= gap <= _BASE_SLACK and (best is None or gap < best[0]): + best = (gap, text, n) + if best is None: + return None, 0, None + _gap, text, n = best + return text, n, run_end - n + + def h3_tokenizer(clip): """The raw HF tokenizer behind an H3 CLIP, or None. diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index ed34e3a8..111fb0d2 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -514,6 +514,13 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No # setting whenever the wired negative is blank, which meant clearing the box left a # negative from a previous session in force with nothing on screen saying so. _rf["negative_prompt"] = str(params.get("negative_prompt") or "") + # The strings a node OUTSIDE Studio actually received — shortcuts rolled, $variables + # resolved. Studio keeps the raw prompt on its own port and expands per scene, so + # without this it cannot know what text a wired conditioning was built from, and + # anything that has to line up with those tokens is guessing. + _exp = params.get("expanded") + if isinstance(_exp, dict) and _exp: + _rf["link_texts"] = {k: str(v) for k, v in _exp.items() if isinstance(v, str)} # Project `$name` variables — Studio resolves them dead-last (after split), per scene. _vars = params.get("variables") if isinstance(_vars, (list, dict)) and _vars: diff --git a/movie_editor/tests/test_builder.py b/movie_editor/tests/test_builder.py index 52f20a5e..6124036c 100644 --- a/movie_editor/tests/test_builder.py +++ b/movie_editor/tests/test_builder.py @@ -1280,3 +1280,15 @@ def test_an_imported_negative_does_not_survive_an_empty_box(): params.pop("negative_prompt", None) graph, _report = builder.build(OI, {"slots": []}, params) assert graph["neg"]["inputs"]["value"] == "" + + +def test_studio_receives_the_texts_linked_nodes_were_given(): + """Studio keeps the RAW prompt on its own port and expands per scene, so without this it + cannot know what string a wired conditioning was built from — and anything that has to + line up with those tokens is guessing.""" + import json + params = {**PARAMS, "expanded": {"prompt": "cinematic rain", "full_prompt": "cinematic rain now"}} + graph, _report = builder.build(OI, {"slots": []}, params) + 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" diff --git a/tests/test_h3_token_weights.py b/tests/test_h3_token_weights.py index bed885ce..ad7a30a5 100644 --- a/tests/test_h3_token_weights.py +++ b/tests/test_h3_token_weights.py @@ -623,3 +623,50 @@ def test_emphasis_still_applies_to_studios_own_encode_via_the_tail(refiner, monk memory = {"rain": {"kind": "phrase", "effective_category_scores": {"details": 0.9}}} tag = _apply(refiner, monkeypatch, {"funpack_encode_text": "cinematic rain"}, memory) assert tag is not None + + +# --- finding the string the tensor was actually encoded from --------------- + +def test_the_candidate_matching_the_text_run_is_chosen(): + """A wired conditioning was encoded by another node from the editor's expansion, not from + Studio's copy. The tag map says how many text positions the tensor holds, so the right + candidate can be CHECKED rather than guessed.""" + tags = [0] * 5 + [1] * 6 # a vision block, then 6 text positions + text, n, base = tw.choose_encoded_text( + _FakeTokenizer(), ["a much longer prompt", "abcdef"], tags, cond_len=11) + + assert text == "abcdef" + assert n == 6 + assert base == 5 + + +def test_the_closest_fit_wins_when_several_are_close(): + tags = [0, 0] + [1] * 8 + text, _n, _b = tw.choose_encoded_text( + _FakeTokenizer(), ["abcdef", "abcdefgh"], tags, cond_len=10) + assert text == "abcdefgh" + + +def test_a_candidate_longer_than_the_run_is_never_chosen(): + """It cannot be what was encoded — the tensor has nowhere to put the extra tokens.""" + tags = [0, 0] + [1] * 4 + assert tw.choose_encoded_text(_FakeTokenizer(), ["abcdefgh"], tags, cond_len=6) \ + == (None, 0, None) + + +def test_no_tags_means_no_verified_choice(): + assert tw.choose_encoded_text(_FakeTokenizer(), ["abc"], None, cond_len=6) \ + == (None, 0, None) + + +def test_the_editor_link_texts_are_offered_as_candidates(): + """server._expanded_link_texts is what a node outside Studio actually received.""" + import conditioning + src = inspect_source(conditioning.FunPackVideoRefinerV2._v2_apply_h3_token_weights) + assert 'link_texts.get(k) for k in ("full_prompt", "prompt")' in src + assert "choose_encoded_text" in src + + +def inspect_source(fn): + import inspect + return inspect.getsource(fn)