feat: phrase blend -- per-blend strength override, [a|b:0.8]

Default 0.5 shift left phraseA dominating the video. [phraseA|phraseB:N]
overrides strength per blend. Fixed a shared regex gap along the way: every
weight/strength markup in this file (not just blend) silently rejected
leading-dot decimals like ".8" -- fell through to literal text with no
error, reproducing the exact "override did nothing" bug this feature exists
to fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-21 22:05:22 +03:00
co-authored by Claude Sonnet 5
parent 1f54ad4519
commit 8a6ab12ce6
4 changed files with 97 additions and 38 deletions
+10 -3
View File
@@ -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
+29 -11
View File
@@ -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"(?<!\\)\(([^():]*?):\s*(-?\d+(?:\.\d+)?)\s*\)")
_WEIGHTED = re.compile(r"(?<!\\)\(([^():]*?):\s*(" + _NUM + r")\s*\)")
# Below this a weight is treated as "remove entirely" rather than log(0) = -inf, which
# produces NaN the moment a query attends to nothing else.
@@ -70,7 +76,7 @@ def parse(text: str):
# Square brackets so a shortcut replacement's parentheses and colons can sit inside; only a
# `:number` directly before the `@t0-t1]` tail is a weight.
_TIMED = re.compile(
r"(?<!\\)\[([^\[\]]*?)(?::\s*(-?\d+(?:\.\d+)?))?\s*@\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*\]")
r"(?<!\\)\[([^\[\]]*?)(?::\s*(" + _NUM + r"))?\s*@\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*\]")
# H3's latent time grid: latent frame k spans FRAME_PER_TOKEN[k % 5] pixel frames at 24 fps.
FPS = 24
FRAME_PER_TOKEN = (1, 4, 4, 4, 4)
@@ -78,7 +84,7 @@ FRAME_PER_TOKEN = (1, 4, 4, 4, 4)
# [phrase:1.5] - a weight with no window, in brackets so a shortcut replacement's own
# parentheses cannot break it (the reason timed phrases use brackets at all).
_BRACKET_WEIGHTED = re.compile(r"(?<!\\)\[([^\[\]@]*?):\s*(-?\d+(?:\.\d+)?)\s*\]")
_BRACKET_WEIGHTED = re.compile(r"(?<!\\)\[([^\[\]@]*?):\s*(" + _NUM + r")\s*\]")
# [phraseA|phraseB]: BLEND. phraseA stays in the prompt Studio encodes (prefix/postfix and
# every other phrase are exactly what was typed); phraseB is a second sentence re-encoded
@@ -104,9 +110,20 @@ _BRACKET_WEIGHTED = re.compile(r"(?<!\\)\[([^\[\]@]*?):\s*(-?\d+(?:\.\d+)?)\s*\]
# always found a way through anyway (`dog:5@2`, `dog@1—2` with an em dash, `@` followed by
# an unrelated dash many words later). Blacklisting a look is not a fixable boundary; not
# blacklisting at all is consistent with how the rest of this file already treats it.
_BLENDED = re.compile(r"(?<!\\)\[([^\[\]|]+)\|([^\[\]|]+)\]")
# Default blend amount: half the distance from phraseA's average hidden state toward
# phraseB's. Not exposed as a Studio dial yet — one number, changeable here, not a UI knob.
#
# An OPTIONAL `:strength` on phraseB overrides `BLEND_STRENGTH_DEFAULT` for that one blend
# — `[cat|dog:0.8]`. This deliberately claims the same trailing "colon then a bare number"
# shape that would otherwise be ambiguous with literal phraseB text, the same way
# `_BRACKET_WEIGHTED` already claims that shape for its own phrase. Non-greedy so an
# ordinary internal colon ("shot: walks") is still just text — only a colon-number
# immediately before the closing `]` is read as a strength override.
_BLENDED = re.compile(r"(?<!\\)\[([^\[\]|]+)\|([^\[\]|]+?)(?::\s*(" + _NUM + r"))?\]")
# Default blend amount when no `:strength` is given: half the distance from phraseA's
# average hidden state toward phraseB's. Applies as a bare fraction, same scale as a typed
# strength (0 = no shift, 1 = fully phraseB's mean, room to overshoot past 1 if asked). Not
# clamped: 0 is a legitimate no-op, and a negative value pushes phraseA's rows AWAY from
# phraseB's mean instead of toward it. Deliberately left open, not validated -- same policy
# as every other weight/strength value in this file.
BLEND_STRENGTH_DEFAULT = 0.5
_MARKUP = re.compile(_TIMED.pattern + "|" + _BLENDED.pattern + "|" + _WEIGHTED.pattern
@@ -117,7 +134,7 @@ def parse_markup(text: str):
"""Strip ALL THREE prompt markups in one left-to-right pass.
-> (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()
+1 -1
View File
@@ -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:
+57 -23
View File
@@ -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))