diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b608df7..56b979e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,10 @@ compounded to an effective x3.4 on one token. A lone span is untouched. And how hard it pushes now comes from Studio's existing machinery rather than a new scale: the ceiling is the same 1.25 `_v2_build_attn2_patch` has used on LTX all along, and the ramp toward it is - `_v2_auto_strength`, so one Awful reaches about 1.13 instead of jumping to maximum. + `_v2_auto_strength`, so one Awful reaches about 1.13 instead of jumping to maximum. It also + measures the text that was ENCODED rather than the raw one, and resolves `$variables` in a + phrase before matching it — phrase memory stores raw text, so a phrase could still carry + `$style` while the encoded prompt had it resolved. ### Fixed - **A reference image is no longer thrown away by Studio.** With CLIP and a positive diff --git a/conditioning.py b/conditioning.py index 374a6c85..42dc7075 100644 --- a/conditioning.py +++ b/conditioning.py @@ -13076,7 +13076,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)), + 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), status, training_info, loss_graph, @@ -13094,7 +13094,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)), + 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), status + enhancement_status, training_info, loss_graph, @@ -13769,7 +13769,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): + auto_strength=None, variables=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 @@ -13820,6 +13820,31 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): "the rating's per-phrase emphasis is NOT being applied") return conditioning_list + def _resolve_phrase_variables(pairs): + """Phrase memory stores the RAW prompt text, so a phrase can still carry `$style` + while the encoded prompt has it resolved — and then it matches nothing. + Variables are a plain substitution, so both sides can be put in the same form. + + Shortcuts are NOT expanded here. A `/trigger` rolls per seed, and peeking would + produce a different roll from the one that was encoded — a phrase carrying an + unrolled shortcut failing to match is the honest outcome.""" + if not variables: + return list(pairs) + try: + try: + from .templates import resolve_variables as _rv + except ImportError: + from templates import resolve_variables as _rv + except Exception: # noqa: BLE001 + return list(pairs) + done = [] + for phrase, weight in pairs: + try: + done.append((_rv(str(phrase), variables)[0], weight)) + except Exception: # noqa: BLE001 + done.append((phrase, weight)) + return done + out = [] applied = 0 for entry in conditioning_list or []: @@ -13827,7 +13852,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): out.append(entry) continue cond, meta = entry[0], dict(entry[1]) - text = str(meta.get("funpack_scene_text") or "").strip() or str(fallback_text or "") + # funpack_encode_text is the string that was actually ENCODED — shortcuts rolled + # 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 "")) try: from . import h3_token_weights as _tw except ImportError: @@ -13835,7 +13866,7 @@ 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. - chosen = _tw.order_for_application(weighted[:8]) + chosen = _tw.order_for_application(_resolve_phrase_variables(weighted[:8])) spans, prompt_tokens = _tw.locate(tokenizer, text, chosen) if spans and prompt_tokens: entry_meta = {"spans": spans, "prompt_tokens": int(prompt_tokens)} @@ -13865,7 +13896,8 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): scene_refinement_keys=None, learning_profile=None, conditioning_plan=None, clip=None, encode_cache=None, phrase_memory=None, axis_feedback=None, - h3_phrase_emphasis=False, auto_strength=None): + h3_phrase_emphasis=False, auto_strength=None, + variables=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 @@ -13883,7 +13915,8 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): axis_feedback=axis_feedback, fallback_text=temporal_fallback_text, enabled=h3_phrase_emphasis, - auto_strength=auto_strength) + auto_strength=auto_strength, + variables=variables) if mode in ("relative", "both"): out = self._v2_apply_scene_refinement_keys( out, scene_refinement_keys, refinement_key, diff --git a/tests/test_h3_token_weights.py b/tests/test_h3_token_weights.py index 7c990244..f5d933ca 100644 --- a/tests/test_h3_token_weights.py +++ b/tests/test_h3_token_weights.py @@ -6,6 +6,7 @@ the prompt's key positions in the packed self-attention stream. """ import math import sys +import types import pytest import torch @@ -536,3 +537,68 @@ def test_a_single_span_is_never_clamped(): weaken a weight the user asked for.""" bias = tw.build_bias([(0, 4, 2.0)], 4, 4, 20, "cpu", torch.float32) assert bias.max().item() == pytest.approx(math.log(2.0)) + + +# --- the text that was actually encoded ------------------------------------ + +@pytest.fixture +def refiner(): + import conditioning + return conditioning.FunPackVideoRefinerV2.__new__(conditioning.FunPackVideoRefinerV2) + + +class _H3Clip: + def __init__(self): + self.tokenizer = types.SimpleNamespace( + qwen3vl_32b=types.SimpleNamespace(tokenizer=_FakeTokenizer())) + + +def _apply(refiner, monkeypatch, meta, memory, variables=None): + import conditioning + import minimax_h3 + monkeypatch.setattr(minimax_h3, "is_h3_clip", lambda c: True) + monkeypatch.setattr(conditioning, "_log", + types.SimpleNamespace(failed=lambda *a, **k: None, + note_on_change=lambda *a, **k: None), + raising=False) + out = refiner._v2_apply_h3_token_weights( + [[torch.zeros(1, 40, 8), dict(meta)]], _H3Clip(), phrase_memory=memory, + axis_feedback={"missing_axes": ["details"]}, enabled=True, + auto_strength=0.0435, variables=variables) + return out[0][1].get("funpack_h3_token_weights") + + +def test_the_encoded_text_is_measured_not_the_raw_one(refiner, monkeypatch): + """funpack_scene_text still holds `$style`; the conditioning was built from the resolved + string. Measuring the raw one puts every span on the wrong words.""" + memory = {"neon rain": {"kind": "phrase", + "effective_category_scores": {"details": 0.9}}} + tag = _apply(refiner, monkeypatch, + {"funpack_scene_text": "$style neon rain", + "funpack_encode_text": "cinematic neon rain"}, memory) + + assert tag is not None + assert tag["prompt_tokens"] == len("cinematic neon rain") + start, end, _w = tag["spans"][0] + assert "cinematic neon rain"[start:end] == "neon rain" + + +def test_a_phrase_still_carrying_a_variable_is_resolved_before_matching(refiner, monkeypatch): + """Phrase memory stores the RAW text, so `$style` survives in the phrase while the + encoded prompt has it resolved — and then it matches nothing.""" + memory = {"$style rain": {"kind": "phrase", + "effective_category_scores": {"details": 0.9}}} + tag = _apply(refiner, monkeypatch, + {"funpack_encode_text": "cinematic rain"}, memory, + variables=[{"name": "style", "value": "cinematic"}]) + + assert tag is not None + start, end, _w = tag["spans"][0] + assert "cinematic rain"[start:end] == "cinematic rain" + + +def test_a_phrase_that_still_does_not_match_is_simply_skipped(refiner, monkeypatch): + memory = {"nothing like it": {"kind": "phrase", + "effective_category_scores": {"details": 0.9}}} + assert _apply(refiner, monkeypatch, {"funpack_encode_text": "cinematic rain"}, + memory) is None