diff --git a/README.md b/README.md index b929f98..8021ad7 100644 --- a/README.md +++ b/README.md @@ -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 `` 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` diff --git a/dumas_h3_longvideos.py b/dumas_h3_longvideos.py index 7839478..c0cb96b 100644 --- a/dumas_h3_longvideos.py +++ b/dumas_h3_longvideos.py @@ -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 tags in ONE shot. @@ -5267,9 +5300,29 @@ class H3LongVideos: "ref_image_7": ("IMAGE", {"tooltip": "Reference image ."}), "ref_image_8": ("IMAGE", {"tooltip": "Reference image ."}), "ref_image_9": ("IMAGE", {"tooltip": "Reference image ."}), - "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 = [] # 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 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 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 "") + \ diff --git a/tests/test_dumas_h3_longvideos.py b/tests/test_dumas_h3_longvideos.py index 7261220..ddfe8cf 100644 --- a/tests/test_dumas_h3_longvideos.py +++ b/tests/test_dumas_h3_longvideos.py @@ -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__":