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>
This commit is contained in:
digital-garbage
2026-09-27 18:11:51 +03:00
co-authored by Claude Opus 5.5
parent 3cd0c1e00a
commit 9d028ccc92
9 changed files with 596 additions and 8 deletions
+2
View File
@@ -54,6 +54,8 @@ FROZEN = {
"FunPackLoadModifiers": ["model", "settings"],
"FunPackLatentUpscalerLoader": ["upscaler_name"],
"FunPackCutOpening": ["images", "frames", "fps", "audio"],
"FunPackPromptMarkup": ["text"],
"FunPackApplyPromptMarkup": ["model", "clip", "positive", "latent", "markup"],
"FunPackLatentResample": ["latent", "upscaler", "operation", "scale", "positive", "negative"],
"FunPackModifierSettings": ["settings"],
"FunPackDecode": ["samples", "vae", "model", "audio_vae"],
View File
@@ -0,0 +1,29 @@
"""Prompt markup -- (word:1.5), [phrase@2-4], [a|b]. A node-only module.
Two nodes, because the markup has to be split around the encoder: it must come
OUT before the encoder sees the text (Qwen reads "(word:1.5)" as punctuation in
your sentence), and it can only be applied AFTER, once there is a conditioning
to find the words in and a model to bias.
text -> Prompt Markup -> clean -> (any H3 encode node) -> positive
-> markup ---------------------> Apply Prompt Markup
(+ model, clip, latent)
Where the words sit, and how seconds map to rows, is the MODEL's business: model
modules announce `text_tokenizer`, `prompt_rows` and `video_time_rows`. With
none that recognise the encoder, the markup is stripped and said to be unused --
never silently ignored.
The weight is an attention BIAS, and SLA runs any biased call dense, so a run
with weights or timed phrases runs dense attention.
"""
from .nodes import FunPackApplyPromptMarkup, FunPackPromptMarkup
ID = "conditioning_prompt_markup"
TITLE = "Prompt markup"
STAGE = "conditioning"
CATEGORY = "conditioning"
STATUS = "proven"
NODES = [FunPackPromptMarkup, FunPackApplyPromptMarkup]
@@ -0,0 +1,135 @@
"""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
+189
View File
@@ -0,0 +1,189 @@
"""The two markup nodes. See the module docstring for the shape."""
import torch
import torch.nn.functional as F
from comfy_api.latest import io
from ..._core import dit_hooks, log, patching, registry as registry_mod
from . import markup as mk
MARKUP = io.Custom("FUNPACK_MARKUP")
KEY = "funpack_markup.prompt"
def _ask(capability, *args):
"""The first model module with an answer, or None."""
for _spec, provider in registry_mod.current().providers(capability):
got = provider(*args)
if got is not None:
return got
return None
def _say(message):
log.once(f"prompt_markup:{message}", log.ALERT, "FunPack Prompt Markup", message)
class FunPackPromptMarkup(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="FunPackPromptMarkup",
display_name="FunPack Prompt Markup",
category="FunPack/Conditioning",
description="Take (word:1.5), [phrase@2-4] and [a|b] out of a prompt, so the "
"encoder sees clean text and the markup can be applied properly.",
inputs=[io.String.Input("text", multiline=True, default="")],
outputs=[io.String.Output(display_name="clean"),
MARKUP.Output(display_name="markup"),
io.String.Output(display_name="status")],
)
@classmethod
def execute(cls, text: str) -> io.NodeOutput:
parsed = mk.parse(text or "")
n = {k: len(parsed[k]) for k in ("weighted", "timed", "blended")}
status = (", ".join(f"{v} {k}" for k, v in n.items() if v) or "no markup")
return io.NodeOutput(parsed["clean"], parsed, status)
def _blend(cond, rows, clip, tokenizer, clean, offsets, blended):
"""(1-s)*A + s*B over the prompt rows, each span measured against the
ORIGINAL A so several blends add instead of compounding."""
start, end = rows
a_rows = cond[:, start:end, :]
delta, done = torch.zeros_like(a_rows), 0
for c0, c1, alt, strength in blended:
if not mk.token_spans(offsets, [(c0, c1)]):
continue
alt_text = clean[:c0] + alt + clean[c1:]
encoded = clip.encode_from_tokens_scheduled(clip.tokenize(alt_text))
b_cond, b_meta = encoded[0][0], encoded[0][1]
n_b = len(tokenizer(alt_text, add_special_tokens=False,
return_offsets_mapping=True)["offset_mapping"])
b_rows = _ask("prompt_rows", b_meta, int(b_cond.shape[1]), n_b)
if b_rows is None:
continue
b = b_cond[:, b_rows[0]:b_rows[1], :].to(a_rows)
if b.shape[1] != a_rows.shape[1]:
# Linear along the token axis: most rows already correspond (only
# the swapped phrase differs); a smoothing, not a word alignment.
b = F.interpolate(b.transpose(1, 2), size=a_rows.shape[1], mode="linear",
align_corners=True).transpose(1, 2)
delta += float(strength) * (b - a_rows)
done += 1
if not done:
return cond, 0
out = cond.clone()
out[:, start:end, :] += delta
return out, done
class FunPackApplyPromptMarkup(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="FunPackApplyPromptMarkup",
display_name="FunPack Apply Prompt Markup",
category="FunPack/Conditioning",
description="Apply the markup FunPack Prompt Markup took out: weights and "
"timed phrases on the model, blends on the conditioning.",
inputs=[io.Model.Input("model"), io.Clip.Input("clip"),
io.Conditioning.Input("positive"), io.Latent.Input("latent"),
MARKUP.Input("markup")],
outputs=[io.Model.Output(display_name="model"),
io.Conditioning.Output(display_name="positive"),
io.String.Output(display_name="status")],
)
@classmethod
def execute(cls, model, clip, positive, latent, markup) -> io.NodeOutput:
patched = model.clone()
patching.strip(patched, "funpack_markup.")
weighted, timed, blended = (markup or {}).get("weighted"), (markup or {}).get("timed"), \
(markup or {}).get("blended")
if not (weighted or timed or blended):
return io.NodeOutput(patched, positive, "no markup")
def refuse(why):
_say(f"markup NOT applied: {why}. The prompt still went in, without it.")
return io.NodeOutput(patched, positive, f"not applied: {why}")
tokenizer = _ask("text_tokenizer", clip)
if tokenizer is None:
return refuse("this text encoder is not one a model module can place words in")
clean = markup["clean"]
offsets = list(tokenizer(clean, add_special_tokens=False,
return_offsets_mapping=True)["offset_mapping"])
cond, meta = positive[0][0], positive[0][1]
cond_len = int(cond.shape[1])
rows = _ask("prompt_rows", meta, cond_len, len(offsets))
if rows is None:
return refuse("the encoded prompt does not match this text -- was the clean "
"text from Prompt Markup what got encoded?")
base = rows[0]
said = []
if blended:
new_cond, n = _blend(cond, rows, clip, tokenizer, clean, offsets, blended)
if n:
positive = [[new_cond, meta], *positive[1:]]
said.append(f"{n} blend(s)")
w_spans = mk.token_spans(offsets, weighted or [])
t_spans = []
for tok0, tok1, w, t0, t1 in mk.token_spans(offsets, timed or []):
window = _ask("video_time_rows", latent["samples"], t0, t1)
if window is None:
_say(f"a timed phrase's window {t0:g}-{t1:g}s misses this clip; ignored")
continue
t_spans.append((tok0, tok1, w, window))
if w_spans or t_spans:
cls._install(patched, w_spans, t_spans, base, cond_len)
if w_spans:
said.append(f"{len(w_spans)} weight(s)")
if t_spans:
said.append(f"{len(t_spans)} timed phrase(s)")
return io.NodeOutput(patched, positive, ", ".join(said) or "nothing placeable")
@staticmethod
def _install(patcher, w_spans, t_spans, base, cond_len):
cache = {}
def build(seq_len, device, dtype):
timed, video_start = [], seq_len
for tok0, tok1, w, (n_video, r0, r1) in t_spans:
video_start = seq_len - n_video
if video_start < cond_len:
_say("this sequence is too short to hold the video the timed "
"phrases were placed on; they are off for this call")
return mk.plan(w_spans, [], base, seq_len, seq_len, device, dtype)
timed.append((tok0, tok1, w, video_start + r0, video_start + r1))
return mk.plan(w_spans, timed, base, seq_len, video_start, device, dtype)
def override(func, q, k, v, heads, *args, mask=None, **kwargs):
def run(m, qq=q):
return func(qq, k, v, heads, *args, mask=m, **kwargs)
# Only the packed self-attention: the prompt plus the streams after it.
# A sequence exactly as long as the prompt is the text-only token
# refiner, which must never see a video window's mask.
if k.ndim != 4 or q.shape[2] != k.shape[2] or k.shape[2] <= cond_len:
return run(mask)
try:
key = (int(k.shape[2]), k.device, k.dtype)
if key not in cache:
cache[key] = build(*key)
plan = cache[key]
if plan is None:
return run(mask)
if not isinstance(plan, list):
return run(plan if mask is None else mask + plan)
outs = [run(b if mask is None else mask + b, q[:, :, a:z].contiguous())
for a, z, b in plan]
return torch.cat(outs, dim=2 if outs[0].ndim == 4 else 1)
except Exception as exc: # noqa: BLE001 -- markup never costs the step
_say(f"markup failed inside attention and was dropped ({exc})")
return run(mask)
dit_hooks.add_attention_override(patcher, KEY, override)
@@ -0,0 +1,137 @@
"""Prompt markup, with H3's REAL tokenizer (offline) and the tiny real H3 model.
The encoder is a stand-in -- one fixed vector per token id -- because Qwen3-VL-32B
is not a unit-test dependency; placement depends on the tokenizer and the tags,
which are real here.
"""
import math
import pytest
import torch
@pytest.fixture(autouse=True)
def _needs_comfy(comfyui):
"""Imports comfy."""
class FakeH3Clip:
def __init__(self):
from comfy.text_encoders.minimax import MiniMaxH3Tokenizer
self.tokenizer = MiniMaxH3Tokenizer(embedding_directory=None, tokenizer_data={})
def tokenize(self, text):
return self.tokenizer.tokenize_with_weights(text)
def encode_from_tokens_scheduled(self, tokens):
ids = [t[0] for t in tokens["qwen3vl_32b"][0]]
rows = torch.stack([torch.randn(48, generator=torch.Generator().manual_seed(int(i)))
for i in ids]).unsqueeze(0)
return [[rows, {"minimax_token_tags": torch.ones(len(ids), dtype=torch.long)}]]
def _encode(clip, text):
return clip.encode_from_tokens_scheduled(clip.tokenize(text))
def _apply(tiny, raw):
from modules.conditioning.prompt_markup.nodes import (FunPackApplyPromptMarkup,
FunPackPromptMarkup)
clean, markup, _ = FunPackPromptMarkup.execute(raw).result
clip = FakeH3Clip()
positive = _encode(clip, clean)
tiny.context = positive[0][0]
model, pos, status = FunPackApplyPromptMarkup.execute(
tiny.patcher, clip, positive, {"samples": tiny.video}, markup).result
return model, pos, status, positive
def _spy_masks(tiny, model):
"""Every (q rows, mask) the backend receives, via an override UNDER ours."""
from core import dit_hooks
seen = []
def spy(func, q, k, v, *a, mask=None, **kw):
seen.append((q.shape[2], mask))
return func(q, k, v, *a, mask=mask, **kw)
under = tiny.patcher.clone()
dit_hooks.add_attention_override(under, "spy", spy)
ours = model.model_options["transformer_options"]["optimized_attention_override"]
# Put ours on top of the spy.
under.model_options["transformer_options"]["optimized_attention_override"] = (
lambda func, *a, **kw: ours(lambda *x, **y: under_spy(func, *x, **y), *a, **kw))
under_spy = lambda func, *a, **kw: spy(func, *a, **kw) # noqa: E731
tiny.run(under)
return seen
def test_the_node_strips_markup_and_reports_it():
from modules.conditioning.prompt_markup.nodes import FunPackPromptMarkup
clean, markup, status = FunPackPromptMarkup.execute("a (red:1.5) car [sits|runs]").result
assert clean == "a red car sits"
assert status == "1 weighted, 1 blended"
def test_a_weight_biases_exactly_that_words_keys(tiny_h3):
model, _p, status, _pos = _apply(tiny_h3, "a (red:1.5) car drives")
assert status == "1 weight(s)"
masks = [m for n, m in _spy_masks(tiny_h3, model) if m is not None]
assert masks, "the bias never reached attention"
row = masks[0].reshape(-1)
# tokens: "a" " red" " car" " drives" -> "red" is token 1, prompt at row 0
assert row[1].item() == pytest.approx(math.log(1.5), rel=1e-4)
assert torch.count_nonzero(row).item() == 1
def test_it_changes_the_output_and_weight_one_does_not(tiny_h3):
base_model, _p, _s, pos = _apply(tiny_h3, "a red car drives")
base = tiny_h3.run(base_model)
weighted, *_ = _apply(tiny_h3, "a (red:3) car drives")
assert not torch.allclose(tiny_h3.run(weighted)[0], base[0])
def test_a_timed_phrase_is_masked_from_video_outside_its_window(tiny_h3):
# Latent frame 0 = pixel frame 0, frame 1 = pixel frames 1-4 at 24 fps;
# 0.05-0.2 s touches only latent frame 1 = video rows 4..8 of 8.
model, _p, status, _pos = _apply(tiny_h3, "a red [car@0.05-0.2] drives")
assert status == "1 timed phrase(s)"
calls = _spy_masks(tiny_h3, model)
# The text-only token refiner runs first on exactly the prompt: untouched.
n, m = calls[0]
assert n == int(tiny_h3.context.shape[1]) and m is None, "the refiner saw the window"
per_block = [(n, m) for n, m in calls if m is not None][:3] # block 0's chunks
assert [n for n, _m in per_block] == [10, 4, 4] # text+audio | video out | in
assert sum(n for n, _m in per_block) == tiny_h3_seq(tiny_h3)
key = [m.reshape(-1)[2].item() for _n, m in per_block] # "car" is token 2
assert key[0] == 0.0, "text/audio rows lost the phrase"
assert key[1] == pytest.approx(-30.0), "video outside the window still sees it"
assert key[2] == 0.0, "video inside the window lost it"
def tiny_h3_seq(tiny):
video_rows = 2 * 2 * 2
return int(tiny.context.shape[1]) + video_rows + 2 * 3
def test_a_blend_moves_only_the_prompt_rows_and_zero_strength_moves_nothing(tiny_h3):
_m, pos, status, original = _apply(tiny_h3, "a red car [drives|flies]")
assert status == "1 blend(s)"
assert not torch.allclose(pos[0][0], original[0][0])
_m, pos0, _s, original0 = _apply(tiny_h3, "a red car [drives|flies:0]")
assert torch.allclose(pos0[0][0], original0[0][0])
def test_an_encoder_no_model_module_knows_is_refused_out_loud(tiny_h3):
from modules.conditioning.prompt_markup.nodes import (FunPackApplyPromptMarkup,
FunPackPromptMarkup)
_clean, markup, _ = FunPackPromptMarkup.execute("a (red:2) car").result
class OtherClip:
tokenizer = object()
positive = [[torch.zeros(1, 4, 48), {}]]
model, pos, status = FunPackApplyPromptMarkup.execute(
tiny_h3.patcher, OtherClip(), positive, {"samples": tiny_h3.video}, markup).result
assert status.startswith("not applied") and pos is positive
+5 -1
View File
@@ -31,6 +31,7 @@ from .pipeline import presets as _pipeline_presets
from .anchor import anchor_pin as _anchor_pin
from .anchor import rescale_pins as _rescale_pins
from .latent_upscaler import load_upscaler as _load_upscaler
from . import prompt_layout as _prompt_layout
from .rows import target_rows as _target_rows
has_block = _traits.has_block
@@ -187,4 +188,7 @@ TRAITS = traits
PROVIDES = {"empty_latent": empty_latent, "decode": decode, "detect": detect,
"pipeline_presets": _pipeline_presets, "probe_traits": probe_traits,
"target_rows": _target_rows, "anchor_pin": _anchor_pin,
"latent_upscaler": _load_upscaler, "rescale_conditioning": _rescale_pins}
"latent_upscaler": _load_upscaler, "rescale_conditioning": _rescale_pins,
"text_tokenizer": _prompt_layout.text_tokenizer,
"prompt_rows": _prompt_layout.prompt_rows,
"video_time_rows": _prompt_layout.video_time_rows}
+13 -7
View File
@@ -79,12 +79,14 @@ def h3_reference_to_video():
"invert_crop": "disabled",
"bboxes": ""}},
{"id": "markup", "group": "Preparation", "node": "FunPackPromptMarkup",
"roles": [{"at": "generation.prompt", "input": "text", "label": "Prompt"}],
"inputs": {"text": ""}},
{"id": "r2v", "group": "Preparation", "node": "MiniMaxH3ReferenceToVideo",
"roles": [{"at": "generation.prompt", "input": "prompt", "label": "Prompt"},
{"at": "project.video", "input": "length", "label": "Length"}],
"roles": [{"at": "project.video", "input": "length", "label": "Length"}],
"inputs": {
"clip": ["clip", 0], "vae": ["vae", 0], "audio_vae": ["audio_vae", 0],
"prompt": "", "width": ["image_transform", 4], "height": ["image_transform", 5],
"prompt": ["markup", 0], "width": ["image_transform", 4], "height": ["image_transform", 5],
"length": 124, "ref_image_size": "match"}},
{"id": "negative", "group": "Preparation", "node": "CLIPTextEncode",
# H3 samples at CFG 1, where a negative prompt does nothing -- wired
@@ -95,13 +97,17 @@ def h3_reference_to_video():
{"id": "modifiers", "group": "Preparation", "node": "FunPackLoadModifiers", "inputs": {
"model": ["shift", 0], "settings": ["settings", 0]}},
{"id": "markup_apply", "group": "Preparation", "node": "FunPackApplyPromptMarkup",
"inputs": {"model": ["modifiers", 0], "clip": ["clip", 0], "positive": ["r2v", 0],
"latent": ["r2v", 1], "markup": ["markup", 1]}},
{"id": "sampler", "group": "Sampling", "node": "FunPackSampler",
"roles": [{"at": "generation.sampling", "input": "steps", "label": "Steps"},
{"at": "generation.sampling", "input": "sampler_name", "label": "Sampler"},
{"at": "generation.sampling", "input": "scheduler", "label": "Scheduler"}],
"inputs": {
"model": ["modifiers", 0], "positive": ["r2v", 0], "negative": ["negative", 0],
"latent": ["r2v", 1], "settings": ["settings", 0],
"model": ["markup_apply", 0], "positive": ["markup_apply", 1],
"negative": ["negative", 0], "latent": ["r2v", 1], "settings": ["settings", 0],
"seed": 0, "steps": 20, "cfg": 1.0,
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0}},
@@ -158,12 +164,12 @@ def h3_reference_to_video_second_pass():
{"id": "resample", "group": "Second pass", "node": "FunPackLatentResample",
"inputs": {"latent": ["sampler", 0], "upscaler": ["upscaler", 0],
"operation": "sharpen", "scale": 2.0,
"positive": ["r2v", 0], "negative": ["negative", 0]}},
"positive": ["markup_apply", 1], "negative": ["negative", 0]}},
{"id": "pass2_sigmas", "group": "Second pass", "node": "ManualSigmas",
"inputs": {"sigmas": "0.4, 0.3, 0.2, 0.1, 0.0"}},
{"id": "pass2", "group": "Second pass", "node": "FunPackSampler",
"inputs": {
"model": ["modifiers", 0], "positive": ["resample", 1],
"model": ["markup_apply", 0], "positive": ["resample", 1],
"negative": ["resample", 2], "latent": ["resample", 0],
"settings": ["settings", 0], "sigmas": ["pass2_sigmas", 0],
"seed": 0, "steps": 4, "cfg": 1.0,
@@ -0,0 +1,86 @@
"""Where H3 puts the prompt, and how its time axis maps to rows.
Three capabilities, so prompt markup never learns H3's layout:
* `text_tokenizer(clip)` -- the raw HF tokenizer behind an H3 CLIP, for exact
character -> token offsets. Tokenizing is free; re-encoding with Qwen3-VL-32B
to find a phrase would cost a 32B forward pass per phrase.
* `prompt_rows(meta, cond_len, prompt_tokens)` -- (start, end) of the prompt
inside a conditioning. The prompt is the LAST thing H3's tokenizer appends
(reference labels and vision blocks come first), and `minimax_token_tags`
marks text 1, vision 0, so it is the tail of the final run of 1s. Measured
from the END so labels in front of it (an audio reference's "<Audio n>: ",
which has no vision block) do not shift it.
* `video_time_rows(samples, t0, t1)` -- a window in seconds as rows of the
target video, which is the last segment of the packed sequence.
The conditioning block leads the packed sequence, so a row inside it is also a
row of the sequence the attention runs over.
"""
FPS = 24
# Latent frame k spans FRAME_PER_TOKEN[k % 5] pixel frames (H3's 5k+2 grid).
FRAME_PER_TOKEN = (1, 4, 4, 4, 4)
def text_tokenizer(clip):
try:
tok = getattr(getattr(getattr(clip, "tokenizer", None), "qwen3vl_32b", None),
"tokenizer", None)
if tok is None:
return None
tok("probe", add_special_tokens=False, return_offsets_mapping=True)
return tok
except Exception: # noqa: BLE001 -- not H3's, or no offsets
return None
def _tags(meta):
tags = (meta or {}).get("minimax_token_tags")
if tags is None:
return None
try:
return [int(t) for t in tags.reshape(-1).tolist()]
except AttributeError:
return [int(t) for t in tags] if isinstance(tags, (list, tuple)) else None
def prompt_rows(meta, cond_len, prompt_tokens):
"""(start, end) of the prompt in the conditioning, or None if it cannot be
proven. Without tags the prompt is the final `prompt_tokens` rows."""
if prompt_tokens <= 0 or cond_len <= 0:
return None
tags = _tags(meta)
if tags is None:
start = cond_len - prompt_tokens
return (start, cond_len) if start >= 0 else None
end = min(len(tags), cond_len)
while end > 0 and tags[end - 1] != 1:
end -= 1
start = end
while start > 0 and tags[start - 1] == 1:
start -= 1
if end - start < prompt_tokens:
return None # a different string was encoded
return end - prompt_tokens, end
def video_time_rows(samples, t0, t1):
"""(video_rows, row_start, row_end) for a window in seconds, rows counted
from the start of the target video segment. None if it misses the clip."""
video = samples.unbind()[0] if getattr(samples, "is_nested", False) else samples
if getattr(video, "ndim", 0) != 5:
return None
latent_t = int(video.shape[2])
frame_rows = ((int(video.shape[3]) + 1) // 2) * ((int(video.shape[4]) + 1) // 2)
cum = [0]
for k in range(latent_t):
cum.append(cum[-1] + FRAME_PER_TOKEN[k % 5])
f0, f1 = int(round(t0 * FPS)), int(round(t1 * FPS))
if f1 <= f0 or f0 >= cum[-1]:
return None
k0 = max(k for k in range(latent_t) if cum[k] <= f0)
k1 = min(latent_t, max(k for k in range(latent_t) if cum[k] < f1) + 1)
if k1 <= k0:
return None
return latent_t * frame_rows, k0 * frame_rows, k1 * frame_rows