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`. - 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)` - `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` - 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. - 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. - 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` - `Dumas H3 Shot Length`
- Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max` - 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", "exposed_terms", "anatomy_guard", "lock_restraints", "solidity_guard",
"motion_guard", "contact_guard", "motion_guard", "contact_guard",
"auto_soundscape", "allow_nonspeech_vocals", "auto_soundscape", "allow_nonspeech_vocals",
"ref_image_5", "ref_image_6", "ref_image_7", "ref_image_8", "ref_image_9",
) )
NL = "\n" NL = "\n"
@@ -3997,6 +3998,11 @@ def picture_tags(text):
return sorted({int(m.group(1)) for m in _PICTURE_TAG.finditer(text or "")}) 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): def resolve_tagged_refs(text, ref_list):
"""(rewritten text, images, dropped) for the <Picture N> tags in ONE shot. """(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 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.""" removed from the text rather than left to confuse the encoder, and reported."""
wanted = picture_tags(text) 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] dropped = [n for n in wanted if n not in live]
renumber = {old: new for new, old in enumerate(live, 1)} 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 back as a soft signal (the model is shown where the last
shot ended rather than told to start exactly there), and it shot ended rather than told to start exactly there), and it
stays a single ref2va task, so nothing conflicts.""" stays a single ref2va task, so nothing conflicts."""
if not ref_list: refs = _connected_refs(ref_list)
if not refs:
return [] return []
if ref_mode == "first shot": 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": if ref_mode == "every shot":
return list(ref_list) return list(refs)
if ref_mode == "every shot + handoff ref": if ref_mode == "every shot + handoff ref":
return list(ref_list) + ([handoff] if handoff is not None else []) return list(refs) + ([handoff] if handoff is not None else [])
return list(ref_list) if shot_index == 0 else [] # unknown value -> safest return list(refs) if shot_index == 0 else [] # unknown value -> safest
# --- text-encoder / DiT compatibility ------------------------------------- # --- text-encoder / DiT compatibility -------------------------------------
@@ -5239,11 +5246,15 @@ class H3LongVideos:
}, },
"optional": { "optional": {
"first_frame": ("IMAGE",), "first_frame": ("IMAGE",),
# ref2va inputs. Order matters and is the ONLY thing that decides the # ref2va inputs. SOCKET NUMBER matters: prompt tags refer to these
# roster: the tokenizer labels these <Picture 1>..<Picture 4> in the # exact slots, even when some intermediate sockets are left empty.
# order they appear here, then appends the prompt. Refer to them by # A shot using only ref_image_7 is still tagged as <Picture 7> in the
# those tags in the prompt if you want a reference bound to a named # prompt and renumbered only for the per-shot tokenizer payload.
# character ("Kristy, <Picture 1>, walks in"). # 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 " "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> " "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 " "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_2": ("IMAGE", {"tooltip": "Reference image <Picture 2>."}),
"ref_image_3": ("IMAGE", {"tooltip": "Reference image <Picture 3>."}), "ref_image_3": ("IMAGE", {"tooltip": "Reference image <Picture 3>."}),
"ref_image_4": ("IMAGE", {"tooltip": "Reference image <Picture 4>."}), "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, "plan_only": ("BOOLEAN", {"default": False,
"tooltip": "Preview the shot split WITHOUT rendering. Uses THIS node's own settings (no " "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 " "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_text="", intro_position="center", intro_seconds=3.0, intro_fade=0.6,
intro_size=9.0, overlay_font="arial.ttf", overlay_stroke=0, 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_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, ref_mode="where tagged", ref_image_size="match", ref_noise_aug=0.999,
graph=None, node_id=None): graph=None, node_id=None):
@@ -6024,8 +6042,11 @@ class H3LongVideos:
else "prompt/soundscape silencing only")) else "prompt/soundscape silencing only"))
# Same reference accounting the render reports: which shots lose the # Same reference accounting the render reports: which shots lose the
# handoff is a composition decision, so it belongs in the preview. # 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) ref_slots = [
if r is not None]) 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 = "" plan_ref = ""
if n_refs: if n_refs:
# Mirror the render's placement exactly: 'where tagged' reads the # 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 # reporting by ref_mode alone described shots the render never gave
# references to. # references to.
if ref_mode == "where tagged" and any(picture_tags(g) for g in gens): 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" how = "placed by <Picture N> tags"
else: else:
mode_eff = "first shot" if ref_mode == "where tagged" else ref_mode mode_eff = "first shot" if ref_mode == "where tagged" else ref_mode
on = [n + 1 for n in range(shots) 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}'" how = (f"ref_mode '{mode_eff}'"
+ (" -- no tags found anywhere" if ref_mode == "where tagged" else "")) + (" -- 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) " 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 latent_chunks = [] # per-shot sampled latents, pre-decode
mouth_settled = [] # shots seeded from a settled (closed) mouth mouth_settled = [] # shots seeded from a settled (closed) mouth
handoff, sr = first_frame, None 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_shots = [] # which shots ended up ref-conditioned
ref_missing = [] # <Picture N> tags naming an unconnected slot ref_missing = [] # <Picture N> tags naming an unconnected slot
ref_carried = [] # tagged shots that kept continuity as an extra ref 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 # 'where tagged' reads the prompt instead of counting shots. If references are
# connected but nothing is tagged anywhere, fall back to first-shot placement # connected but nothing is tagged anywhere, fall back to first-shot placement
# rather than silently conditioning nothing at all. # 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) 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" ref_mode = "first shot"
if cleanup_between_shots: if cleanup_between_shots:
_deep_cleanup() # start the first (heaviest) shot with max free VRAM _deep_cleanup() # start the first (heaviest) shot with max free VRAM
@@ -6384,10 +6409,10 @@ class H3LongVideos:
f"dropped from the text") f"dropped from the text")
else: else:
ref_note_missing = "" 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] 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_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"{','.join(str(n) for n in ref_shots)} "
f"({ref_placement})" f"({ref_placement})"
+ (f", ref_noise_aug {ref_noise_aug:.3f}" if ref_noise_aug is not None + (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) + ("" if (ref_keyframed or ref_carried or kept)
else ", so every cut between beats is a CUT, not a continuous take") else ", so every cut between beats is a CUT, not a continuous take")
+ ref_note_missing) + ref_note_missing)
elif ref_list: elif connected_ref_count:
ref_note = (f" ref2va: {len(ref_list)} reference image(s) connected but ref_mode " ref_note = (f" ref2va: {connected_ref_count} reference image(s) connected but ref_mode "
f"'{ref_mode}' applied them to no shot") f"'{ref_mode}' applied them to no shot")
else: else:
ref_note = "" ref_note = ""
+45
View File
@@ -132,6 +132,51 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
fn(spoken) fn(spoken)
self.assertGreaterEqual(fn.cache_info().hits, 2) 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__": if __name__ == "__main__":
unittest.main() unittest.main()