diff --git a/.tests/test_minimax_h3_director.py b/.tests/test_minimax_h3_director.py index cc8e0ec..ad5baaf 100644 --- a/.tests/test_minimax_h3_director.py +++ b/.tests/test_minimax_h3_director.py @@ -372,12 +372,16 @@ def test_director_exposes_frame_rate_output_and_validates_range(): node.build_guide("FL2VA", "", 1344, 768, 5, "match", "{}", frame_rate=out_of_range) -def test_director_input_types_declare_frame_rate_directly_under_duration(): +def test_director_input_types_keep_legacy_widget_order_and_put_frame_rate_last(): 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 + # The eight widgets that predate frame_rate must keep their original + # positional order so that older saved widgets_values arrays still map to + # the right slots; frame_rate is appended last so it never shifts them. + assert keys[:8] == ["mode", "prompt", "width", "height", "duration", + "ref_image_size", "timeline_data", "builder_state"] + assert keys.index("frame_rate") == len(keys) - 1 def test_director_keeps_only_external_prompt_overwrite_as_its_prompt_input(): diff --git a/nodes/nodes_minimax_h3_director.py b/nodes/nodes_minimax_h3_director.py index 2d7f8bc..db428ff 100644 --- a/nodes/nodes_minimax_h3_director.py +++ b/nodes/nodes_minimax_h3_director.py @@ -32,10 +32,10 @@ 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}), + "frame_rate": ("FLOAT", {"default": 24.0, "min": 0.1, "max": 240.0, "step": 0.01}), }, "optional": { "fl2va_model": ("MODEL", {"lazy": True}), @@ -70,7 +70,13 @@ 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) + # A non-numeric frame_rate (e.g. a stale 9th widgets_value shifted in by an + # older save, or an empty string) falls back to the default instead of crashing + # the queue; genuinely out-of-range numbers still raise. + try: + frame_rate = float(frame_rate) + except (TypeError, ValueError): + frame_rate = 24.0 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 diff --git a/test/test_minimax_h3_director_prompt_compatibility.py b/test/test_minimax_h3_director_prompt_compatibility.py index 62a92da..57c9cb4 100644 --- a/test/test_minimax_h3_director_prompt_compatibility.py +++ b/test/test_minimax_h3_director_prompt_compatibility.py @@ -1,4 +1,6 @@ """Regression tests for Director prompt migration and standard prompt output.""" +import json + from nodes.helper_minimax_h3_prompt_builder import ( build_prompt, default_builder_state, @@ -32,3 +34,79 @@ def test_frontend_keeps_standard_prompt_widget_serialized(): assert "promptWidget.value = resolved" in source assert "promptWidget.callback?.(resolved)" in source assert "state.resolved_prompt = resolved" in source + + +def _required_order(): + """Return the widget names in the exact order ComfyUI's positional loader + assigns them, which is the INPUT_TYPES iteration order (required first).""" + schema = MiniMaxH3Director.INPUT_TYPES() + names = [] + for section in ("required", "optional"): + for key in schema.get(section, {}): + if key in ("fl2va_model", "ref2va_model", + "external_width_overwrite", "external_height_overwrite", + "external_prompt_overwrite"): + continue # sockets, not widgets + names.append(key) + return names + + +def _load_old_widgets_values(values): + """Positionally map a saved widgets_values array onto the current node's + widget slots, exactly as the ComfyUI graph loader does.""" + order = _required_order() + node = MiniMaxH3Director() + positional = {key: values[i] for i, key in enumerate(order[: len(values)])} + # Sockets / optional widgets not present in the old save keep their defaults. + return node, positional + + +def _real_ref2va_builder_state(): + """A minimal v1 builder_state carrying real REF2VA content (the kind that + lives in the embedded metadata of the old videos).""" + return json.dumps({ + "version": 1, + "mode": "REF2VA", + "ref": { + "subject_defs": [{"text": "a red sandstone lion"}], + "summary_text": "transfer the lion's mane texture onto the character", + "summary_types": ["reference generation"], + "retention": [], + "style_line": "", + "detail": "", + }, + }) + + +def test_old_9_value_save_loads_without_crashing_and_preserves_prompt(): + """A save from before frame_rate was a required input has 9 widgets_values + where position 8 is the removed external_prompt widget (''). After the + reordering, that stale value lands on frame_rate and must not crash the + queue; the real builder_state (position 7) must round-trip to a prompt.""" + node, kwargs = _load_old_widgets_values([ + "REF2VA", "", 1344, 768, 5, "match", + json.dumps({"version": 1, "items": [], "prompt_blocks": []}), + _real_ref2va_builder_state(), + "", # stale 9th value: was external_prompt, now lands on frame_rate + ]) + + guide, *_ , out_frame_rate = node.build_guide( + **{**kwargs, "frame_rate": kwargs.get("frame_rate", 24.0)} + ) + + # The stale value is coerced to the default instead of raising. + assert out_frame_rate == 24.0 + # The builder_state is reachable and produced a non-empty prompt. + assert guide["resolved_prompt"].strip() + assert "lion" in guide["resolved_prompt"] + + +def test_out_of_range_frame_rate_still_raises(): + node = MiniMaxH3Director() + try: + node.build_guide("REF2VA", "", 1344, 768, 5, "match", "{}", "", + frame_rate=9999.0) + except ValueError: + return + raise AssertionError("expected ValueError for out-of-range frame_rate") +