Wire H3 long videos to plan scene refs
This commit is contained in:
@@ -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 scene’s 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`
|
||||
|
||||
+81
-13
@@ -56,15 +56,26 @@ import node_helpers
|
||||
|
||||
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)
|
||||
_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,6 +5300,26 @@ 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": (
|
||||
"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 "
|
||||
@@ -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):
|
||||
|
||||
@@ -5784,6 +5838,14 @@ class H3LongVideos:
|
||||
f"computed at {H3_FPS}; set your video-save node to {H3_FPS} too")
|
||||
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
|
||||
@@ -6042,10 +6104,6 @@ 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:
|
||||
@@ -6062,9 +6120,15 @@ class H3LongVideos:
|
||||
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 ""))
|
||||
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"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,11 +6472,17 @@ 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"; 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 "
|
||||
@@ -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 "") + \
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user