Files
olivv-cs-ComfyUI-FunPack/modules/conditioning/prompt_markup/markup.py
T
digital-garbageandClaude Opus 5.5 9d028ccc92 feat(v5): prompt markup for H3 -- (word:1.5), [phrase@t0-t1], [a|b]
Prompt Markup strips it before the encoder; Apply Prompt Markup puts weights
and timed windows on attention and blends on the conditioning. Where the words
sit is the H3 module's (text_tokenizer, prompt_rows, video_time_rows). Wired
into both H3 presets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:11:51 +03:00

136 lines
5.8 KiB
Python

"""Prompt markup: the text part. Pure -- strings in, spans out, tensors built.
Three markups, stripped in ONE left-to-right pass so every span indexes the
same clean text (stripping them one after another left the first list's
offsets pointing into a string that no longer existed):
* `(phrase:1.5)` / `[phrase:1.5]` -- weight. A logit bias of log(w) on that
phrase's key positions: softmax(logits + log w) multiplies that key's share
of the attention by w, which is what a weight is supposed to mean.
* `[phrase@2-3.5]` / `[phrase:1.5@2-3.5]` -- a timed phrase: only the video
rows inside the window see it (weighted); outside, it is masked from video.
Text and audio rows keep the untimed bias. Confirmed working on H3 in v4.
* `[phraseA|phraseB]` / `[A|B:0.8]` -- blend: (1-s)*A + s*B conditioning,
row by row. Confirmed running end-to-end in v4; visual quality unjudged.
Weighting only exists because the text is IN the attention sequence (H3's
single packed stream). Scaling embeddings would not work: Qwen's rows are
contextual hidden states, with no per-word magnitude to turn up.
"""
import math
import re
_NUM = r"-?(?:\d+\.\d*|\.\d+|\d+)"
_WEIGHTED = re.compile(r"(?<!\\)\(([^():]*?):\s*(" + _NUM + r")\s*\)")
_TIMED = re.compile(
r"(?<!\\)\[([^\[\]]*?)(?::\s*(" + _NUM + r"))?\s*@\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*\]")
_BRACKET_WEIGHTED = re.compile(r"(?<!\\)\[([^\[\]@]*?):\s*(" + _NUM + r")\s*\]")
_BLENDED = re.compile(r"(?<!\\)\[([^\[\]|]+)\|([^\[\]|]+?)(?::\s*(" + _NUM + r"))?\]")
_MARKUP = re.compile("|".join(p.pattern for p in (_TIMED, _BLENDED, _WEIGHTED, _BRACKET_WEIGHTED)))
BLEND_STRENGTH_DEFAULT = 0.5
# Below this a weight means "remove": log(0) is -inf, which is NaN the moment a
# query attends to nothing else.
MIN_WEIGHT = 1e-3
MASKED_BIAS = -30.0
# Past this a phrase wins every query regardless of content: the prompt reads as
# collapsing onto one word.
MAX_ABS_BIAS = 6.0
# Overlapping spans (a phrase and one of its own words) add, bounded at this
# multiple of the strongest single span -- a lone (word:2.0) still means 2.0.
OVERLAP_HEADROOM = 1.5
def parse(text):
"""-> {"clean", "weighted": [(c0, c1, w)], "timed": [(c0, c1, w, t0, t1)],
"blended": [(c0, c1, alt, strength)]}, char spans into `clean`."""
out = {"clean": text or "", "weighted": [], "timed": [], "blended": []}
if not text or ("(" not in text and "[" not in text):
return out
pieces, pos = [], 0
for m in _MARKUP.finditer(text):
pieces.append(text[pos:m.start()])
start = sum(len(p) for p in pieces)
if m.group(1) is not None:
phrase, t0, t1 = m.group(1), float(m.group(3)), float(m.group(4))
if t1 > t0 and phrase.strip():
out["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:
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():
out["blended"].append((start, start + len(phrase), alt.strip(), strength))
else:
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)
out["weighted"].append((start, start + len(phrase), float(weight)))
pieces.append(phrase)
pos = m.end()
pieces.append(text[pos:])
out["clean"] = "".join(pieces)
return out
def token_spans(offsets, char_spans):
"""Char spans -> (tok0, tok1, *rest) using a tokenizer's offset mapping."""
out = []
for c0, c1, *rest in char_spans:
toks = [i for i, (a, b) in enumerate(offsets) if a < c1 and b > c0]
if toks:
out.append((toks[0], toks[-1] + 1, *rest))
return out
def bias_value(weight):
if weight <= MIN_WEIGHT:
return MASKED_BIAS
return max(-MAX_ABS_BIAS, min(MAX_ABS_BIAS, math.log(weight)))
def key_bias(spans, base, seq_len, device, dtype):
"""[1, 1, 1, seq_len] additive bias for (tok0, tok1, w) spans, prompt at `base`."""
import torch
bias, strongest = None, 0.0
for t0, t1, weight in spans:
b = bias_value(weight)
if b == 0.0 or t1 <= t0:
continue
if bias is None:
bias = torch.zeros(1, 1, 1, seq_len, device=device, dtype=dtype)
bias[..., base + t0:base + t1] += b
strongest = max(strongest, abs(b))
if bias is not None:
limit = min(MAX_ABS_BIAS, strongest * OVERLAP_HEADROOM)
bias.clamp_(min=-limit, max=limit)
return bias
def plan(weighted, timed, base, seq_len, video_start, device, dtype):
"""The bias to add, as one tensor, or [(q0, q1, bias)] query chunks when a
phrase is timed. Softmax is per query row, so attending the video rows in
chunks, each with its own key bias, is exact -- it only adds kernel launches.
`timed` = [(tok0, tok1, w, r0, r1)] with r0/r1 absolute rows of the sequence.
"""
import torch
base_bias = key_bias(weighted, base, seq_len, device, dtype)
if not timed:
return base_bias
edges = sorted({0, video_start, seq_len, *(r for *_x, r0, r1 in timed for r in (r0, r1))})
chunks = []
for a, b in zip(edges, edges[1:]):
if b <= a:
continue
bias = (base_bias.clone() if base_bias is not None
else torch.zeros(1, 1, 1, seq_len, device=device, dtype=dtype))
if a >= video_start:
for t0, t1, w, r0, r1 in timed:
if r0 <= a and b <= r1:
bias[..., base + t0:base + t1] += bias_value(w)
else:
bias[..., base + t0:base + t1] = MASKED_BIAS
chunks.append((a, b, bias))
return chunks