diff --git a/conditioning.py b/conditioning.py index 5253b991..d7350458 100644 --- a/conditioning.py +++ b/conditioning.py @@ -12264,7 +12264,14 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): return out def _v2_apply_h3_phrase_blend(self, conditioning_list, clip, link_texts=None): - """`[phraseA|phraseB]` — pull phraseA's rows toward phraseB's, in place. + """`[phraseA|phraseB(:strength)?]` — pull phraseA's rows toward phraseB's, in place. + + `strength` defaults to `h3_token_weights.BLEND_STRENGTH_DEFAULT` (0.5) when omitted; + each span carries its own value (parsed per-blend, not a single run-wide setting), + since a half-strength pull still reads as mostly phraseA — the rest of the sentence + (style, postfix, grammar around the phrase) is 100% built around phraseA's words + regardless of how far the mean shift moves, so getting phraseB's influence to show + up at all in the video can need pushing well past 0.5. phraseA is what `_v2_encode_prompt` already put in the prompt (see `funpack_h3_blended` in `_v2_encode_prompt`); prefix, postfix and every other word @@ -12390,7 +12397,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): continue new_cond = cond.clone() done = 0 - for start, end, alt_phrase in spans: + for start, end, alt_phrase, strength in spans: toks = _tw.token_spans_from_offsets(offsets, [(start, end, 1.0)]) if not toks: skipped_n += 1 @@ -12430,7 +12437,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): mean_b = alt_cond[:, blo:bhi, :].mean(dim=1, keepdim=True).to( dtype=new_cond.dtype, device=new_cond.device) new_cond[:, lo:hi, :] = new_cond[:, lo:hi, :] \ - + _tw.BLEND_STRENGTH_DEFAULT * (mean_b - mean_a) + + float(strength) * (mean_b - mean_a) done += 1 if done: cond = new_cond diff --git a/h3_token_weights.py b/h3_token_weights.py index 945356d4..b5b616f5 100644 --- a/h3_token_weights.py +++ b/h3_token_weights.py @@ -24,8 +24,14 @@ import math import re from typing import Optional +# A signed number a weight/strength may be written as, including a leading-dot shorthand +# (".8") that `-?\d+(?:\.\d+)?` alone would silently refuse -- refused text falls through to +# being read as literal phrase content instead of a weight, with nothing to say the number +# was never applied. +_NUM = r"-?(?:\d+\.\d*|\.\d+|\d+)" + # (phrase:1.3) / (phrase:-0.4). Escaped \( is left alone so a literal bracket still works. -_WEIGHTED = re.compile(r"(? (clean_text, weighted [(start, end, weight)], timed [(start, end, weight, t0, t1)], - blended [(start, end, alt_phrase)]). + blended [(start, end, alt_phrase, strength)]). One pass, so every span list indexes the same clean text; stripping them one after the other would leave the first list's offsets pointing into a string that no longer exists. """ @@ -133,13 +150,14 @@ def parse_markup(text: str): if t1 > t0 and phrase.strip(): timed.append((start, start + len(phrase), float(m.group(2)) if m.group(2) else 1.0, t0, t1)) - elif m.group(5) is not None: # [phraseA|phraseB] + elif m.group(5) is not None: # [phraseA|phraseB(:strength)?] phrase, alt = m.group(5), m.group(6) + strength = float(m.group(7)) if m.group(7) is not None else BLEND_STRENGTH_DEFAULT if phrase.strip() and alt.strip(): - blended.append((start, start + len(phrase), alt.strip())) + blended.append((start, start + len(phrase), alt.strip(), strength)) else: # (phrase:w) or [phrase:w] - phrase = m.group(7) if m.group(7) is not None else m.group(9) - weight = m.group(8) if m.group(8) is not None else m.group(10) + phrase = m.group(8) if m.group(8) is not None else m.group(10) + weight = m.group(9) if m.group(9) is not None else m.group(11) weighted.append((start, start + len(phrase), float(weight))) out.append(phrase) pos = m.end() diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py index 8d097dd8..79c81459 100644 --- a/movie_editor/backend/bridge.py +++ b/movie_editor/backend/bridge.py @@ -112,7 +112,7 @@ def expand_prompt_with_windows(text: str, variables=None): which never sees the encoded string when a wired node owns the prompt - needs them handed over separately, as char spans on the clean text. -> (clean_expanded_text, timed [(start, end, weight, t0, t1)], - weighted [(start, end, weight)], blended [(start, end, alt_phrase)]) + weighted [(start, end, weight)], blended [(start, end, alt_phrase, strength)]) """ expanded = expand_prompt_fragment(text) if expanded and variables: diff --git a/tests/test_h3_token_weights.py b/tests/test_h3_token_weights.py index ad4e9650..92a1ca16 100644 --- a/tests/test_h3_token_weights.py +++ b/tests/test_h3_token_weights.py @@ -827,18 +827,38 @@ def test_a_blended_phrase_is_parsed_and_phrase_a_stays_in_the_clean_text(): assert clean == "a cat sits on a mat" assert weighted == [] assert timed == [] - assert blended == [(6, 10, "stands")] + assert blended == [(6, 10, "stands", tw.BLEND_STRENGTH_DEFAULT)] def test_a_blend_alongside_a_weight_and_a_window_in_one_pass(): clean, weighted, timed, blended = tw.parse_markup( "a cat [sits|stands] then [runs@1-2] (fast:1.5)") assert clean == "a cat sits then runs fast" - assert blended == [(6, 10, "stands")] + assert blended == [(6, 10, "stands", tw.BLEND_STRENGTH_DEFAULT)] assert timed == [(16, 20, 1.0, 1.0, 2.0)] assert weighted == [(21, 25, 1.5)] +def test_a_blend_strength_suffix_overrides_the_default(): + clean, weighted, timed, blended = tw.parse_markup("a cat [sits|stands:0.8] man") + assert clean == "a cat sits man" + assert blended == [(6, 10, "stands", 0.8)] + + +def test_a_leading_dot_strength_is_a_number_not_literal_text(): + """`:.8`, `:5.` and a negative leading-dot -- the natural shorthand for typing a decimal + -- must parse as numbers here, same as everywhere else a weight/strength is written in + this file. `-?\\d+(?:\\.\\d+)?` alone silently rejects a bare leading dot, which falls + through to the alt phrase being read as literal text (":.8" stays in the sentence) with + no error and no log line -- exactly reproducing the "my strength override did nothing" + complaint this syntax exists to fix. Shared `_NUM` fixes this for every weight-style + markup in the file, not just blend.""" + assert tw.parse_markup("a [cat|dog:.8] man")[3] == [(2, 5, "dog", 0.8)] + assert tw.parse_markup("a [cat|dog:5.] man")[3] == [(2, 5, "dog", 5.0)] + assert tw.parse_markup("a [cat|dog:-.5] man")[3] == [(2, 5, "dog", -0.5)] + assert tw.parse_markup("a (cat:.8) man")[1] == [(2, 5, 0.8)] + + def test_an_empty_alternate_phrase_is_not_a_blend(): assert tw.parse_markup("a cat [sits| ]")[3] == [] @@ -990,7 +1010,7 @@ def test_the_blended_span_is_shifted_by_half_the_mean_difference(refiner, monkey text = "a cat sits on a mat" # "sits" is chars [6:10] cond = torch.zeros(1, len(text), 4) cond[:, 6:10, :] = 1.0 - meta = {"funpack_h3_blended": [(6, 10, "stands")], "funpack_h3_timed_text": text, + meta = {"funpack_h3_blended": [(6, 10, "stands", 0.5)], "funpack_h3_timed_text": text, "minimax_token_tags": [1] * len(text)} out = refiner._v2_apply_h3_phrase_blend([[cond, meta]], _H3Clip()) result = out[0][0] @@ -999,6 +1019,20 @@ def test_the_blended_span_is_shifted_by_half_the_mean_difference(refiner, monkey assert torch.allclose(result[:, 10:, :], torch.zeros(1, len(text) - 10, 4)) +def test_a_higher_blend_strength_shifts_further_toward_the_alternate(refiner, monkeypatch): + """Same setup as above, but strength 0.8 instead of the 0.5 default: span should land at + 1.0 + 0.8*(5.0-1.0) = 4.2 — this is what the user asked for after reporting phraseA + dominating visually at the default strength.""" + _quiet(monkeypatch) + text = "a cat sits on a mat" + cond = torch.zeros(1, len(text), 4) + cond[:, 6:10, :] = 1.0 + meta = {"funpack_h3_blended": [(6, 10, "stands", 0.8)], "funpack_h3_timed_text": text, + "minimax_token_tags": [1] * len(text)} + out = refiner._v2_apply_h3_phrase_blend([[cond, meta]], _H3Clip()) + assert torch.allclose(out[0][0][:, 6:10, :], torch.full((1, 4, 4), 4.2)) + + def test_an_untagged_entry_is_left_completely_alone(refiner, monkeypatch): _quiet(monkeypatch) cond = torch.zeros(1, 8, 4) @@ -1014,7 +1048,7 @@ def test_wired_conditioning_blends_from_the_editors_link_texts(refiner, monkeypa phrases. `choose_encoded_text` verifies "cat runs" against the tensor's own tag run (3 reference tokens + 8 text tokens) before trusting it.""" _quiet(monkeypatch) - link = {"prompt": "cat runs", "full_prompt": "cat runs", "blended": [[4, 8, "stands"]]} + link = {"prompt": "cat runs", "full_prompt": "cat runs", "blended": [[4, 8, "stands", 0.5]]} cond = torch.zeros(1, 11, 4) cond[:, 7:11, :] = 1.0 # "runs" sits after the 3-token ref block meta = {"funpack_conditioning_owner": "wired", "minimax_token_tags": [0, 0, 0] + [1] * 8} @@ -1036,7 +1070,7 @@ def test_wired_conditioning_is_skipped_not_silently_dropped_when_nothing_matches feature=lambda *a, **k: logged.append(a)), raising=False) link = {"prompt": "totally different text", "full_prompt": "totally different text", - "blended": [[4, 8, "stands"]]} + "blended": [[4, 8, "stands", 0.5]]} cond = torch.zeros(1, 11, 4) cond[:, 7:11, :] = 1.0 meta = {"funpack_conditioning_owner": "wired", "minimax_token_tags": [0, 0, 0] + [1] * 8} @@ -1057,25 +1091,25 @@ def test_a_non_h3_clip_is_left_alone(refiner, monkeypatch): import minimax_h3 monkeypatch.setattr(minimax_h3, "is_h3_clip", lambda c: False) cond = torch.zeros(1, 8, 4) - meta = {"funpack_h3_blended": [(0, 3, "x")], "funpack_h3_timed_text": "cat runs"} + meta = {"funpack_h3_blended": [(0, 3, "x", 0.5)], "funpack_h3_timed_text": "cat runs"} out = refiner._v2_apply_h3_phrase_blend([[cond, meta]], _H3Clip()) assert out[0][0] is cond # --- adversarial-review fixes: a colon in the alt phrase, double-apply, partial failure ---- -def test_an_alt_phrase_shaped_like_a_weight_is_accepted_as_literal_text(): - """[cat|dog:1.5] is a blend whose alt phrase is the literal string "dog:1.5" -- colon - and digit intact. It is NOT stripped before its separate re-encode (there is no bracket - left around it once substituted into the sentence), so Qwen reads it as literal - punctuation in that phrase's own sentence -- a quality footnote, not a leak, and the - same trade-off `[cat (sitting):1.5]` already gets elsewhere in this file (kept as - literal pass-through text, not re-parsed). Blacklisting "looks like a weight" was tried - and abandoned: it also caught ordinary colons in prose with nothing to do with weights, - and a DIFFERENT shape (`dog:5@2`, an em-dash instead of a hyphen) always found a way - through anyway -- see git history on this test for the two reverted attempts.""" +def test_a_trailing_colon_number_on_the_alt_phrase_is_now_a_strength_override(): + """[cat|dog:1.5] used to keep "dog:1.5" as literal alt-phrase text (colon and digit both + just part of the phrase, a deliberate accepted quality trade-off -- see + feedback_dont_blacklist_by_shape). Per-blend strength syntax repurposes exactly this + shape on purpose: a colon immediately followed by a number and the closing bracket is + now read as `strength`, not literal text, same as `(phrase:weight)` elsewhere in this + file. Only a colon in that exact tail position triggers it -- `dog:5@2` below is NOT a + valid number-then-bracket tail, so it stays literal, and an ordinary colon anywhere else + in either phrase (`test_an_ordinary_colon_in_either_phrase_is_a_perfectly_fine_blend`) + is untouched.""" clean, weighted, timed, blended = tw.parse_markup("a [cat|dog:1.5] man") - assert blended == [(2, 5, "dog:1.5")] + assert blended == [(2, 5, "dog", 1.5)] assert clean == "a cat man" @@ -1098,13 +1132,13 @@ def test_an_ordinary_colon_in_either_phrase_is_a_perfectly_fine_blend(): the construct just never matched. A colon that isn't sitting at the very end next to a bare number is ordinary prompt text and must be allowed through untouched.""" clean, weighted, timed, blended = tw.parse_markup("a [wide shot: walks|runs fast] man") - assert blended == [(2, 18, "runs fast")] + assert blended == [(2, 18, "runs fast", tw.BLEND_STRENGTH_DEFAULT)] assert clean == "a wide shot: walks man" def test_an_ordinary_at_sign_in_either_phrase_is_a_perfectly_fine_blend(): clean, weighted, timed, blended = tw.parse_markup("a [call me@work|call me@home] man") - assert blended == [(2, 14, "call me@home")] + assert blended == [(2, 14, "call me@home", tw.BLEND_STRENGTH_DEFAULT)] assert clean == "a call me@work man" @@ -1128,7 +1162,7 @@ def test_a_malformed_time_window_shaped_alt_phrase_is_still_a_blend(alt): test_an_alt_phrase_shaped_like_a_weight_is_accepted_as_literal_text for why blocking by shape was tried and abandoned rather than fixed further.""" clean, weighted, timed, blended = tw.parse_markup(f"a [cat|{alt}] man") - assert blended == [(2, 5, alt)] + assert blended == [(2, 5, alt, tw.BLEND_STRENGTH_DEFAULT)] assert clean == "a cat man" @@ -1141,7 +1175,7 @@ def test_applying_the_blend_twice_does_not_compound(refiner, monkeypatch): text = "a cat sits on a mat" cond = torch.zeros(1, len(text), 4) cond[:, 6:10, :] = 1.0 - meta = {"funpack_h3_blended": [(6, 10, "stands")], "funpack_h3_timed_text": text, + meta = {"funpack_h3_blended": [(6, 10, "stands", 0.5)], "funpack_h3_timed_text": text, "minimax_token_tags": [1] * len(text)} once = refiner._v2_apply_h3_phrase_blend([[cond, meta]], _H3Clip()) twice = refiner._v2_apply_h3_phrase_blend(once, _H3Clip()) @@ -1157,7 +1191,7 @@ def test_applying_a_wired_blend_twice_with_the_same_link_texts_does_not_compound would re-tag and re-blend it without a separate, never-popped 'already handled' marker.""" _quiet(monkeypatch) - link = {"prompt": "cat runs", "full_prompt": "cat runs", "blended": [[4, 8, "stands"]]} + link = {"prompt": "cat runs", "full_prompt": "cat runs", "blended": [[4, 8, "stands", 0.5]]} cond = torch.zeros(1, 11, 4) cond[:, 7:11, :] = 1.0 meta = {"funpack_conditioning_owner": "wired", "minimax_token_tags": [0, 0, 0] + [1] * 8} @@ -1175,7 +1209,7 @@ def test_one_failed_span_is_reported_not_swallowed(refiner, monkeypatch, capsys) text = "a cat sits on a mat" cond = torch.zeros(1, len(text), 4) cond[:, 6:10, :] = 1.0 - meta = {"funpack_h3_blended": [(6, 10, "stands"), (100, 104, "nowhere")], + meta = {"funpack_h3_blended": [(6, 10, "stands", 0.5), (100, 104, "nowhere", 0.5)], "funpack_h3_timed_text": text, "minimax_token_tags": [1] * len(text)} out = refiner._v2_apply_h3_phrase_blend([[cond, meta]], _H3Clip()) assert torch.allclose(out[0][0][:, 6:10, :], torch.full((1, 4, 4), 3.0))