diff --git a/.tests/test_minimax_h3_director.py b/.tests/test_minimax_h3_director.py index 1973a7f..7782fd2 100644 --- a/.tests/test_minimax_h3_director.py +++ b/.tests/test_minimax_h3_director.py @@ -86,7 +86,7 @@ def test_director_emits_v2_consolidated_prompt_for_i2va_builder_state(): builder = default_builder_state("I2VA") builder.update({"imd": "A bright room.", "soundscape": "birds", "music": "N/A"}) - guide, _, resolved, _, _, _, fl2va_requested, ref2va_requested = director.MiniMaxH3Director().build_guide( + guide, _, resolved, _, _, _, fl2va_requested, ref2va_requested, _ = director.MiniMaxH3Director().build_guide( "I2VA", "legacy", 1344, 768, 5, "match", json.dumps(state), json.dumps(builder) ) @@ -103,10 +103,10 @@ def test_director_blank_external_prompt_falls_back_to_builder(): builder = default_builder_state("T2VA") builder.update({"imd": "[Shot 1] A calm lake at dawn.", "soundscape": "light wind", "music": "N/A"}) - def resolved_for(external_prompt): + def resolved_for(external_prompt_overwrite): return director.MiniMaxH3Director().build_guide( "T2VA", "", 1344, 768, 5, "match", json.dumps({"items": []}), - json.dumps(builder), None, None, external_prompt=external_prompt + json.dumps(builder), None, None, external_prompt_overwrite=external_prompt_overwrite )[2] # Blank string and whitespace-only must both resolve via the builder (non-empty). @@ -293,6 +293,44 @@ def test_director_uses_the_native_h3_frame_grid_for_guide_and_output_length(): assert output_length == 124 +def test_director_exposes_frame_rate_output_and_validates_range(): + node = director.MiniMaxH3Director() + + # Default frame_rate is 24 and is emitted as the final output. + *_, default_fps = node.build_guide("FL2VA", "", 1344, 768, 5, "match", "{}") + assert default_fps == 24.0 + assert isinstance(default_fps, float) + + # A selected frame_rate passes straight through to the output. + *_, chosen_fps = node.build_guide("FL2VA", "", 1344, 768, 5, "match", "{}", frame_rate=60) + assert chosen_fps == 60.0 + + # Fractional rates pass through, while the declared bounds are enforced. + *_, fractional = node.build_guide("FL2VA", "", 1344, 768, 5, "match", "{}", frame_rate=23.976) + assert fractional == 23.976 + *_, low = node.build_guide("FL2VA", "", 1344, 768, 5, "match", "{}", frame_rate=0.1) + assert low == 0.1 + *_, high = node.build_guide("FL2VA", "", 1344, 768, 5, "match", "{}", frame_rate=240) + assert high == 240.0 + for out_of_range in (0.09, 240.01): + with pytest.raises(ValueError, match="frame_rate must be between 0.1 and 240"): + node.build_guide("FL2VA", "", 1344, 768, 5, "match", "{}", frame_rate=out_of_range) + + +def test_director_input_types_declare_frame_rate_directly_under_duration(): + required = director.MiniMaxH3Director.INPUT_TYPES()["required"] + assert required["frame_rate"] == ("FLOAT", {"default": 24.0, "min": 0.1, "max": 240.0, "step": 0.01}) + keys = list(required) + # The widget must render directly beneath duration in the node UI. + assert keys.index("frame_rate") == keys.index("duration") + 1 + + +def test_director_keeps_only_external_prompt_overwrite_as_its_prompt_input(): + optional = director.MiniMaxH3Director.INPUT_TYPES()["optional"] + assert "external_prompt_overwrite" in optional + assert "external_prompt" not in optional + + def test_fl2va_slot_two_without_slot_one_is_the_closing_frame(): closing_frame = "closing.png" state = {"items": [{ diff --git a/nodes/nodes_minimax_h3_director.py b/nodes/nodes_minimax_h3_director.py index e4c3fb7..88ecb1e 100644 --- a/nodes/nodes_minimax_h3_director.py +++ b/nodes/nodes_minimax_h3_director.py @@ -31,6 +31,7 @@ class MiniMaxH3Director: "width": ("INT", {"default": 1344, "min": 16, "max": 8192, "step": 16}), "height": ("INT", {"default": 768, "min": 16, "max": 8192, "step": 16}), "duration": ("INT", {"default": 5, "min": 1, "max": 1000}), + "frame_rate": ("FLOAT", {"default": 24.0, "min": 0.1, "max": 240.0, "step": 0.01}), "ref_image_size": (["match", "max"], {"default": "match"}), "timeline_data": ("STRING", {"default": "{\"version\":1,\"items\":[],\"prompt_blocks\":[]}", "multiline": False, "hidden": True}), "builder_state": ("STRING", {"default": "", "multiline": False, "hidden": True}), @@ -44,21 +45,21 @@ class MiniMaxH3Director: }, } - RETURN_TYPES = ("MINIMAX_H3_DIRECTOR_GUIDE", "INT", "STRING", "INT", "INT", "MODEL", "BOOLEAN", "BOOLEAN") - RETURN_NAMES = ("guide", "duration", "positive_prompt", "width", "height", "model", "fl2va_requested", "ref2va_requested") + RETURN_TYPES = ("MINIMAX_H3_DIRECTOR_GUIDE", "INT", "STRING", "INT", "INT", "MODEL", "BOOLEAN", "BOOLEAN", "FLOAT") + RETURN_NAMES = ("guide", "duration", "positive_prompt", "width", "height", "model", "fl2va_requested", "ref2va_requested", "frame_rate") FUNCTION = "build_guide" CATEGORY = "DaSiWa Nodes/MiniMax H3" def check_lazy_status(self, mode, prompt, width, height, duration, ref_image_size, timeline_data, builder_state, fl2va_model=None, ref2va_model=None, external_width_overwrite=None, - external_height_overwrite=None, external_prompt_overwrite=None, external_prompt=None): + external_height_overwrite=None, external_prompt_overwrite=None, frame_rate=24.0): selected_name = "ref2va_model" if mode == "REF2VA" else "fl2va_model" selected_model = ref2va_model if mode == "REF2VA" else fl2va_model return [selected_name] if selected_model is None else [] def build_guide(self, mode, prompt, width, height, duration, ref_image_size, timeline_data, builder_state="", fl2va_model=None, ref2va_model=None, external_width_overwrite=None, - external_height_overwrite=None, external_prompt_overwrite=None, external_prompt=None): + external_height_overwrite=None, external_prompt_overwrite=None, frame_rate=24.0): # Preserve direct Python callers that used the pre-builder positional model argument. if builder_state is not None and not isinstance(builder_state, str): if fl2va_model is None: @@ -68,6 +69,9 @@ class MiniMaxH3Director: raise ValueError("builder_state must be JSON text") if mode not in BASE_MODES | {"REF2VA"}: raise ValueError(f"unsupported MiniMax Director mode: {mode}") + frame_rate = float(frame_rate) + if not 0.1 <= frame_rate <= 240.0: + raise ValueError("MiniMax Director frame_rate must be between 0.1 and 240") external_canvas = external_width_overwrite is not None or external_height_overwrite is not None if external_canvas: if external_width_overwrite is None or external_height_overwrite is None: @@ -173,9 +177,8 @@ class MiniMaxH3Director: validate_reference_limits(images=images, videos=videos, audios=audios) blocks = state.get("prompt_blocks", []) - prompt_overwrite = external_prompt_overwrite if external_prompt_overwrite is not None else external_prompt - if isinstance(prompt_overwrite, str) and prompt_overwrite.strip(): - resolved = prompt_overwrite + if isinstance(external_prompt_overwrite, str) and external_prompt_overwrite.strip(): + resolved = external_prompt_overwrite else: resolved = build_prompt(merged) if not any(str(merged.get(key) or "").strip() for key in ("imd", "soundscape")) and mode != "REF2VA": @@ -192,8 +195,8 @@ class MiniMaxH3Director: } normalize_guide(guide) selected_model = ref2va_model if mode == "REF2VA" else fl2va_model - log_dasiwa("MiniMax H3 Director", f"mode={mode}; requested_model={'ref2va_model' if mode == 'REF2VA' else 'fl2va_model'}; passed_model={_describe_model(selected_model)}; canvas={width}x{height}; frames={length}; refs=images:{len(ref_images)},videos:{len(ref_videos)},video_audio:{len(ref_video_audios)},audio:{len(ref_audios)}; timeline_items={len(items)}") - return guide, length, resolved, int(width), int(height), selected_model, mode in BASE_MODES, mode == "REF2VA" + log_dasiwa("MiniMax H3 Director", f"mode={mode}; requested_model={'ref2va_model' if mode == 'REF2VA' else 'fl2va_model'}; passed_model={_describe_model(selected_model)}; canvas={width}x{height}; frames={length}; fps={frame_rate}; refs=images:{len(ref_images)},videos:{len(ref_videos)},video_audio:{len(ref_video_audios)},audio:{len(ref_audios)}; timeline_items={len(items)}") + return guide, length, resolved, int(width), int(height), selected_model, mode in BASE_MODES, mode == "REF2VA", frame_rate NODE_CLASS_MAPPINGS = {"MiniMaxH3Director": MiniMaxH3Director}