Expand H3 ref2v slots and tag routing

This commit is contained in:
2026-08-25 19:35:48 +00:00
parent 27d1b76cd5
commit efce589cc5
3 changed files with 163 additions and 92 deletions
+2 -1
View File
@@ -34,10 +34,11 @@
- Reads back the nine optional images for a selected MiniMax H3 plan scene, for example by connecting the current `clip_index`.
- `Dumas H3 Long Videos (FL2VA + REF2VA)`
- Inputs: H3 model stack, prompt socket, optional `first_frame`, optional `ref_image_1`..`ref_image_4`, plus the upstream long-video control surface for pacing, continuity, audio, overlays, and guards
- Inputs: H3 model stack, prompt socket, optional `first_frame`, optional `ref_image_1`..`ref_image_9`, plus the upstream long-video control surface for pacing, continuity, audio, overlays, and guards
- Outputs: `images`, `audio`, `info`, `script`, `frames_per_shot`, `total_frames`, `shots`, `video_seconds`, `fps`, `fps_int`, `latent`, `soundscape`
- First-pass Dumas port of the `MiniMax-H3-Longvideos` sampler, brought in as a local starting point for long-form H3 chaining work.
- Keeps the upstream split-beats / handoff / ref-routing behavior close to source so future Dumas-specific improvements can be compared against a known baseline.
- Prompt `<Picture N>` tags now map to the actual ref socket numbers you wire, even with gaps such as only `ref_image_2` and `ref_image_7` connected.
- `Dumas H3 Shot Length`
- Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max`
+47 -22
View File
@@ -264,6 +264,7 @@ ADDED_WIDGETS = (
"exposed_terms", "anatomy_guard", "lock_restraints", "solidity_guard",
"motion_guard", "contact_guard",
"auto_soundscape", "allow_nonspeech_vocals",
"ref_image_5", "ref_image_6", "ref_image_7", "ref_image_8", "ref_image_9",
)
NL = "\n"
@@ -3997,6 +3998,11 @@ def picture_tags(text):
return sorted({int(m.group(1)) for m in _PICTURE_TAG.finditer(text or "")})
def _connected_refs(ref_slots):
"""Connected refs only, preserving slot order and skipping empty sockets."""
return [ref for ref in (ref_slots or []) if ref is not None]
def resolve_tagged_refs(text, ref_list):
"""(rewritten text, images, dropped) for the <Picture N> tags in ONE shot.
@@ -4009,7 +4015,7 @@ def resolve_tagged_refs(text, ref_list):
A tag naming a slot with no image connected refers to nothing at all, so it is
removed from the text rather than left to confuse the encoder, and reported."""
wanted = picture_tags(text)
live = [n for n in wanted if 1 <= n <= len(ref_list or [])]
live = [n for n in wanted if 1 <= n <= len(ref_list or []) and ref_list[n - 1] is not None]
dropped = [n for n in wanted if n not in live]
renumber = {old: new for new, old in enumerate(live, 1)}
@@ -4047,15 +4053,16 @@ def shot_references(ref_list, ref_mode, shot_index, handoff):
back as a soft signal (the model is shown where the last
shot ended rather than told to start exactly there), and it
stays a single ref2va task, so nothing conflicts."""
if not ref_list:
refs = _connected_refs(ref_list)
if not refs:
return []
if ref_mode == "first shot":
return list(ref_list) if shot_index == 0 else []
return list(refs) if shot_index == 0 else []
if ref_mode == "every shot":
return list(ref_list)
return list(refs)
if ref_mode == "every shot + handoff ref":
return list(ref_list) + ([handoff] if handoff is not None else [])
return list(ref_list) if shot_index == 0 else [] # unknown value -> safest
return list(refs) + ([handoff] if handoff is not None else [])
return list(refs) if shot_index == 0 else [] # unknown value -> safest
# --- text-encoder / DiT compatibility -------------------------------------
@@ -5239,11 +5246,15 @@ class H3LongVideos:
},
"optional": {
"first_frame": ("IMAGE",),
# ref2va inputs. Order matters and is the ONLY thing that decides the
# roster: the tokenizer labels these <Picture 1>..<Picture 4> in the
# order they appear here, then appends the prompt. Refer to them by
# those tags in the prompt if you want a reference bound to a named
# character ("Kristy, <Picture 1>, walks in").
# ref2va inputs. SOCKET NUMBER matters: prompt tags refer to these
# exact slots, even when some intermediate sockets are left empty.
# A shot using only ref_image_7 is still tagged as <Picture 7> in the
# prompt and renumbered only for the per-shot tokenizer payload.
# When a mode uses all connected refs, they keep socket order.
# The tokenizer labels the carried refs <Picture 1>..<Picture N> in
# the order they are handed that shot.
# Refer to socket tags in the prompt if you want a reference bound to
# a named character ("Kristy, <Picture 7>, walks in").
"ref_image_1": ("IMAGE", {"tooltip": "Reference image <Picture 1> -- identity/appearance "
"carried into the shots. Which shots receive it is set by ref_mode (or <Picture N> "
"tags in the beats); a referenced shot ALSO carries the previous frame as its "
@@ -5251,6 +5262,11 @@ class H3LongVideos:
"ref_image_2": ("IMAGE", {"tooltip": "Reference image <Picture 2>."}),
"ref_image_3": ("IMAGE", {"tooltip": "Reference image <Picture 3>."}),
"ref_image_4": ("IMAGE", {"tooltip": "Reference image <Picture 4>."}),
"ref_image_5": ("IMAGE", {"tooltip": "Reference image <Picture 5>."}),
"ref_image_6": ("IMAGE", {"tooltip": "Reference image <Picture 6>."}),
"ref_image_7": ("IMAGE", {"tooltip": "Reference image <Picture 7>."}),
"ref_image_8": ("IMAGE", {"tooltip": "Reference image <Picture 8>."}),
"ref_image_9": ("IMAGE", {"tooltip": "Reference image <Picture 9>."}),
"plan_only": ("BOOLEAN", {"default": False,
"tooltip": "Preview the shot split WITHOUT rendering. Uses THIS node's own settings (no "
"second node, no duplicate entry): returns the plan in 'info' and the "
@@ -5746,6 +5762,8 @@ class H3LongVideos:
intro_text="", intro_position="center", intro_seconds=3.0, intro_fade=0.6,
intro_size=9.0, overlay_font="arial.ttf", overlay_stroke=0,
ref_image_1=None, ref_image_2=None, ref_image_3=None, ref_image_4=None,
ref_image_5=None, ref_image_6=None, ref_image_7=None, ref_image_8=None,
ref_image_9=None,
ref_mode="where tagged", ref_image_size="match", ref_noise_aug=0.999,
graph=None, node_id=None):
@@ -6024,8 +6042,11 @@ class H3LongVideos:
else "prompt/soundscape silencing only"))
# Same reference accounting the render reports: which shots lose the
# handoff is a composition decision, so it belongs in the preview.
n_refs = len([r for r in (ref_image_1, ref_image_2, ref_image_3, ref_image_4)
if r is not None])
ref_slots = [
ref_image_1, ref_image_2, ref_image_3, ref_image_4, ref_image_5,
ref_image_6, ref_image_7, ref_image_8, ref_image_9,
]
n_refs = len(_connected_refs(ref_slots))
plan_ref = ""
if n_refs:
# Mirror the render's placement exactly: 'where tagged' reads the
@@ -6033,12 +6054,12 @@ class H3LongVideos:
# reporting by ref_mode alone described shots the render never gave
# references to.
if ref_mode == "where tagged" and any(picture_tags(g) for g in gens):
on = [n + 1 for n, g in enumerate(gens) if picture_tags(g)]
on = [n + 1 for n, g in enumerate(gens) if resolve_tagged_refs(g, ref_slots)[1]]
how = "placed by <Picture N> tags"
else:
mode_eff = "first shot" if ref_mode == "where tagged" else ref_mode
on = [n + 1 for n in range(shots)
if shot_references([1] * n_refs, mode_eff, n, 1 if n else None)]
if shot_references(ref_slots, mode_eff, n, 1 if n else None)]
how = (f"ref_mode '{mode_eff}'"
+ (" -- no tags found anywhere" if ref_mode == "where tagged" else ""))
plan_ref = (f" ref2va: {n_refs} reference image(s) at '{ref_image_size}' on shot(s) "
@@ -6076,7 +6097,11 @@ class H3LongVideos:
latent_chunks = [] # per-shot sampled latents, pre-decode
mouth_settled = [] # shots seeded from a settled (closed) mouth
handoff, sr = first_frame, None
ref_list = [r for r in (ref_image_1, ref_image_2, ref_image_3, ref_image_4) if r is not None]
ref_list = [
ref_image_1, ref_image_2, ref_image_3, ref_image_4, ref_image_5,
ref_image_6, ref_image_7, ref_image_8, ref_image_9,
]
connected_ref_count = len(_connected_refs(ref_list))
ref_shots = [] # which shots ended up ref-conditioned
ref_missing = [] # <Picture N> tags naming an unconnected slot
ref_carried = [] # tagged shots that kept continuity as an extra ref
@@ -6084,9 +6109,9 @@ class H3LongVideos:
# 'where tagged' reads the prompt instead of counting shots. If references are
# connected but nothing is tagged anywhere, fall back to first-shot placement
# rather than silently conditioning nothing at all.
tag_driven = bool(ref_list) and ref_mode == "where tagged" and any(
tag_driven = bool(connected_ref_count) and ref_mode == "where tagged" and any(
picture_tags(g) for g in gens)
if ref_list and ref_mode == "where tagged" and not tag_driven:
if connected_ref_count and ref_mode == "where tagged" and not tag_driven:
ref_mode = "first shot"
if cleanup_between_shots:
_deep_cleanup() # start the first (heaviest) shot with max free VRAM
@@ -6384,10 +6409,10 @@ class H3LongVideos:
f"dropped from the text")
else:
ref_note_missing = ""
if ref_list and ref_shots:
if connected_ref_count and ref_shots:
kept = [n for n in range(1, len(gens) + 1) if n not in ref_shots]
ref_placement = "placed by <Picture N> tags" if tag_driven else f"ref_mode '{ref_mode}'"
ref_note = (f" ref2va: {len(ref_list)} reference image(s) at '{ref_image_size}' on shot(s) "
ref_note = (f" ref2va: {connected_ref_count} reference image(s) at '{ref_image_size}' on shot(s) "
f"{','.join(str(n) for n in ref_shots)} "
f"({ref_placement})"
+ (f", ref_noise_aug {ref_noise_aug:.3f}" if ref_noise_aug is not None
@@ -6404,8 +6429,8 @@ class H3LongVideos:
+ ("" if (ref_keyframed or ref_carried or kept)
else ", so every cut between beats is a CUT, not a continuous take")
+ ref_note_missing)
elif ref_list:
ref_note = (f" ref2va: {len(ref_list)} reference image(s) connected but ref_mode "
elif connected_ref_count:
ref_note = (f" ref2va: {connected_ref_count} reference image(s) connected but ref_mode "
f"'{ref_mode}' applied them to no shot")
else:
ref_note = ""
+45
View File
@@ -132,6 +132,51 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
fn(spoken)
self.assertGreaterEqual(fn.cache_info().hits, 2)
def test_resolve_tagged_refs_preserves_sparse_socket_numbers(self):
refs = [None, "img2", None, None, None, None, "img7", None, "img9"]
text, images, dropped = self.module.resolve_tagged_refs(
"Mara <Picture 7> turns toward Jon <Picture 2> while <Picture 9> watches.",
refs,
)
self.assertEqual(
text,
"Mara <Picture 2> turns toward Jon <Picture 1> while <Picture 3> watches.",
)
self.assertEqual(images, ["img2", "img7", "img9"])
self.assertEqual(dropped, [])
def test_resolve_tagged_refs_drops_unconnected_sparse_slots(self):
refs = [None, "img2", None, None, None, None, "img7", None, None]
text, images, dropped = self.module.resolve_tagged_refs(
"Use <Picture 7>, skip <Picture 4>, keep <Picture 2>.",
refs,
)
self.assertEqual(text, "Use <Picture 2>, skip, keep <Picture 1>.")
self.assertEqual(images, ["img2", "img7"])
self.assertEqual(dropped, [4])
def test_shot_references_uses_all_connected_sparse_slots(self):
refs = [None, "img2", None, "img4", None, None, "img7", None, None]
self.assertEqual(
self.module.shot_references(refs, "first shot", 0, None),
["img2", "img4", "img7"],
)
self.assertEqual(
self.module.shot_references(refs, "every shot", 3, None),
["img2", "img4", "img7"],
)
def test_input_types_expose_nine_ref_slots(self):
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
for index in range(1, 10):
self.assertIn(f"ref_image_{index}", optional)
if __name__ == "__main__":
unittest.main()