Wire H3 long videos to plan scene refs

This commit is contained in:
2026-08-25 20:01:15 +00:00
parent efce589cc5
commit 4f27c16505
3 changed files with 150 additions and 33 deletions
+2 -1
View File
@@ -34,11 +34,12 @@
- 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_9`, 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`, optional `plan`, optional `plan_scene_index`, 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.
- A connected H3 plan can now supply the current scenes 9-image bundle directly; any directly-wired `ref_image_*` socket overrides the same numbered plan slot.
- `Dumas H3 Shot Length`
- Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max`
+100 -32
View File
@@ -54,17 +54,28 @@ import comfy.nested_tensor
import comfy.model_management as mm
import node_helpers
try:
try:
from . import dumas_h3_overlay as _overlay
from . import dumas_image_nodes as _image_nodes
except ImportError: # loaded as a bare file (test_prompt_logic.py), not as a package
import importlib.util as _ilu
import os as _os
import sys as _sys
_spec = _ilu.spec_from_file_location(
"dumas_h3_overlay",
_os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "dumas_h3_overlay.py"),
)
_overlay = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_overlay)
_overlay = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_overlay)
_image_nodes = _sys.modules.get("dumas_image_nodes")
if _image_nodes is None:
_img_spec = _ilu.spec_from_file_location(
"dumas_image_nodes",
_os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "dumas_image_nodes.py"),
)
_image_nodes = _ilu.module_from_spec(_img_spec)
_sys.modules["dumas_image_nodes"] = _image_nodes
_img_spec.loader.exec_module(_image_nodes)
AUDIO_LATENT_FPS = 40
GB = 1024 ** 3
@@ -265,6 +276,7 @@ ADDED_WIDGETS = (
"motion_guard", "contact_guard",
"auto_soundscape", "allow_nonspeech_vocals",
"ref_image_5", "ref_image_6", "ref_image_7", "ref_image_8", "ref_image_9",
"plan", "plan_scene_index",
)
NL = "\n"
@@ -4003,6 +4015,27 @@ def _connected_refs(ref_slots):
return [ref for ref in (ref_slots or []) if ref is not None]
def _plan_scene_refs(plan, scene_index):
"""Nine ref slots for one plan scene, or all-empty when no usable binding exists."""
if plan is None:
return (None,) * _image_nodes._H3_PLAN_IMAGE_SLOTS
extracted = _image_nodes.DumasH3PlanExtractSceneImagesNode().extract(plan, scene_index)
return tuple(extracted[1:1 + _image_nodes._H3_PLAN_IMAGE_SLOTS])
def _merge_ref_slots(direct_slots, plan_slots):
"""Directly-wired refs win; plan-scene refs fill the empty sockets."""
direct_slots = tuple(direct_slots or ())
plan_slots = tuple(plan_slots or ())
width = max(len(direct_slots), len(plan_slots), _image_nodes._H3_PLAN_IMAGE_SLOTS)
out = []
for index in range(width):
direct = direct_slots[index] if index < len(direct_slots) else None
plan_ref = plan_slots[index] if index < len(plan_slots) else None
out.append(direct if direct is not None else plan_ref)
return tuple(out)
def resolve_tagged_refs(text, ref_list):
"""(rewritten text, images, dropped) for the <Picture N> tags in ONE shot.
@@ -5267,9 +5300,29 @@ class H3LongVideos:
"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 "
"plan": (
"H3_CHAIN_PLAN",
{
"tooltip": "Optional H3 plan enriched by Dumas H3 Plan Attach Scene Images. "
"When connected, this node can pull the selected scene's nine "
"image slots directly instead of rewiring them by hand."
},
),
"plan_scene_index": (
"INT",
{
"default": 1,
"min": 1,
"max": 9999,
"step": 1,
"tooltip": "1-based plan scene index to read from `plan`. "
"Any directly-wired ref_image socket overrides the same slot "
"from the plan scene."
},
),
"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 "
"shots/frames/seconds outputs near-instantly. Turn off to render for real."}),
"fps": ("INT", {"default": 24, "min": 1, "max": 60,
"tooltip": "DISPLAY ONLY -- H3 always renders 24 fps. The model's frame grid and its "
@@ -5764,6 +5817,7 @@ class H3LongVideos:
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,
plan=None, plan_scene_index=1,
ref_mode="where tagged", ref_image_size="match", ref_noise_aug=0.999,
graph=None, node_id=None):
@@ -5782,10 +5836,18 @@ class H3LongVideos:
fps_note = ("" if int(fps) == H3_FPS else
f"fps widget is {int(fps)} but H3 always renders {H3_FPS} fps -- all durations "
f"computed at {H3_FPS}; set your video-save node to {H3_FPS} too")
fps = H3_FPS
w, h = parse_resolution(resolution)
# A pixel budget overrides the preset's SIZE while keeping its aspect ratio,
# so the dropdown chooses the shape and this chooses how big. Scaling from
fps = H3_FPS
w, h = parse_resolution(resolution)
direct_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,
)
plan_ref_slots = _plan_scene_refs(plan, plan_scene_index)
ref_slots = _merge_ref_slots(direct_ref_slots, plan_ref_slots)
plan_ref_count = len(_connected_refs(plan_ref_slots))
direct_ref_count = len(_connected_refs(direct_ref_slots))
# A pixel budget overrides the preset's SIZE while keeping its aspect ratio,
# so the dropdown chooses the shape and this chooses how big. Scaling from
# the preset's own dimensions is what makes 1.00MP reproduce each native
# size exactly -- the preset NAMES are approximations (1344x768 is 7:4, not
# 16:9), so computing from a nominal ratio would not.
@@ -6042,14 +6104,10 @@ 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.
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
plan_ref = ""
if n_refs:
# Mirror the render's placement exactly: 'where tagged' reads the
# prompts and falls back to first shot when nothing is tagged --
# reporting by ref_mode alone described shots the render never gave
# references to.
@@ -6060,11 +6118,17 @@ class H3LongVideos:
mode_eff = "first shot" if ref_mode == "where tagged" else ref_mode
on = [n + 1 for n in range(shots)
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) "
f"{','.join(str(n) for n in on) or 'none'} ({how}) -> those shots keep "
f"the previous frame as their keyframe too, unless ref_noise_aug was lowered")
how = (f"ref_mode '{mode_eff}'"
+ (" -- no tags found anywhere" if ref_mode == "where tagged" else ""))
src = []
if direct_ref_count:
src.append(f"{direct_ref_count} direct")
if plan_ref_count:
src.append(f"{plan_ref_count} from plan scene {int(plan_scene_index)}")
plan_ref = (f" ref2va: {n_refs} reference image(s) at '{ref_image_size}' on shot(s) "
f"{','.join(str(n) for n in on) or 'none'} ({how}) -> those shots keep "
f"the previous frame as their keyframe too, unless ref_noise_aug was lowered"
+ (f" [source: {', '.join(src)}]" if src else ""))
plan = ((anchor_note + " ") if anchor_note else "") + \
preflight_txt + \
(("DIALOGUE MAY BE CUT OFF -- " + "; ".join(fit_warnings) + ". ") if fit_warnings else "") + \
@@ -6097,11 +6161,7 @@ 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 = [
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))
connected_ref_count = len(_connected_refs(ref_slots))
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
@@ -6129,7 +6189,7 @@ class H3LongVideos:
# The prompt itself says where each reference belongs: the shot whose
# text names <Picture N> gets image N, renumbered to match what that
# shot actually carries. Every untagged shot keeps its handoff.
gen_prompt, shot_refs, dropped = resolve_tagged_refs(gen_prompt, ref_list)
gen_prompt, shot_refs, dropped = resolve_tagged_refs(gen_prompt, ref_slots)
for n in dropped:
if n not in ref_missing:
ref_missing.append(n)
@@ -6149,7 +6209,7 @@ class H3LongVideos:
shot_refs = shot_refs + [handoff]
ref_carried.append(i + 1)
else:
shot_refs = shot_references(ref_list, ref_mode, i, handoff)
shot_refs = shot_references(ref_slots, ref_mode, i, handoff)
# ComfyUI 0.31+ lets references and a keyframe ride TOGETHER, and only
# the tagged branch above was ever updated for it. Everywhere else a
# ref-conditioned shot still dropped its handoff, as 0.30 required:
@@ -6412,13 +6472,19 @@ class H3LongVideos:
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_source = []
if direct_ref_count:
ref_source.append(f"{direct_ref_count} direct")
if plan_ref_count:
ref_source.append(f"{plan_ref_count} from plan scene {int(plan_scene_index)}")
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
and float(ref_noise_aug) < 0.999 else "")
+ (f"; shot(s) {','.join(str(n) for n in kept)} keep the handoff" if kept
else "")
+ (f"; source {' + '.join(ref_source)}" if ref_source else "")
+ (f"; shot(s) {','.join(str(n) for n in kept)} keep the handoff" if kept
else "")
+ (f"; shot(s) {','.join(str(n) for n in ref_keyframed)} carry the previous "
f"frame as a real KEYFRAME alongside their references, so they anchor "
f"rather than cut" if ref_keyframed else "")
@@ -6431,7 +6497,9 @@ class H3LongVideos:
+ ref_note_missing)
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")
f"'{ref_mode}' applied them to no shot"
+ (f" (source {' + '.join(([f'{direct_ref_count} direct'] if direct_ref_count else []) + ([f'{plan_ref_count} from plan scene {int(plan_scene_index)}'] if plan_ref_count else []))})"
if (direct_ref_count or plan_ref_count) else ""))
else:
ref_note = ""
info = ((anchor_note + " ") if anchor_note else "") + \
+48
View File
@@ -18,6 +18,11 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
"comfy.nested_tensor",
"comfy.model_management",
"node_helpers",
"numpy",
"PIL",
"PIL.Image",
"folder_paths",
"dumas_image_nodes",
"dumas_h3_longvideos",
)
}
@@ -26,6 +31,17 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
cuda=types.SimpleNamespace(OutOfMemoryError=RuntimeError),
float32="float32",
)
fake_numpy = types.SimpleNamespace(
clip=lambda array, _low, _high: array,
uint8="uint8",
)
fake_pil_image_module = types.SimpleNamespace(fromarray=lambda _array: None)
fake_pil_module = types.SimpleNamespace(Image=fake_pil_image_module)
fake_folder_paths = types.SimpleNamespace(
get_temp_directory=lambda: "/tmp",
get_output_directory=lambda: "/tmp",
get_save_image_path=lambda prefix, _out, _width, _height: ("/tmp", prefix, 1, "", prefix),
)
fake_nodes = types.SimpleNamespace(common_ksampler=lambda *args, **kwargs: ({},))
fake_comfy_samplers = types.SimpleNamespace(
KSampler=types.SimpleNamespace(SAMPLERS=("res_multistep",), SCHEDULERS=("simple",))
@@ -48,6 +64,10 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
)
sys.modules["torch"] = fake_torch
sys.modules["numpy"] = fake_numpy
sys.modules["PIL"] = fake_pil_module
sys.modules["PIL.Image"] = fake_pil_image_module
sys.modules["folder_paths"] = fake_folder_paths
sys.modules["nodes"] = fake_nodes
sys.modules["comfy"] = fake_comfy
sys.modules["comfy.utils"] = fake_comfy_utils
@@ -56,6 +76,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
sys.modules["comfy.model_management"] = fake_mm
sys.modules["node_helpers"] = types.SimpleNamespace()
cls.image_module = importlib.import_module("dumas_image_nodes")
cls.module = importlib.import_module("dumas_h3_longvideos")
@classmethod
@@ -176,6 +197,33 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
for index in range(1, 10):
self.assertIn(f"ref_image_{index}", optional)
self.assertIn("plan", optional)
self.assertIn("plan_scene_index", optional)
def test_plan_scene_refs_reads_bound_images(self):
attach = self.image_module.DumasH3PlanAttachSceneImagesNode()
plan = {"shots": [{"id": "one"}, {"id": "two"}]}
image2 = object()
image7 = object()
plan, _ = attach.attach(plan=plan, scene_index=2, image2=image2, image7=image7)
refs = self.module._plan_scene_refs(plan, 2)
self.assertEqual(len(refs), 9)
self.assertIsNone(refs[0])
self.assertIs(refs[1], image2)
self.assertIs(refs[6], image7)
def test_merge_ref_slots_prefers_direct_refs_over_plan_refs(self):
merged = self.module._merge_ref_slots(
(None, "direct2", None, None, "direct5", None, None, None, None),
("plan1", "plan2", "plan3", None, "plan5", None, "plan7", None, None),
)
self.assertEqual(
merged,
("plan1", "direct2", "plan3", None, "direct5", None, "plan7", None, None),
)
if __name__ == "__main__":