fix(minimax-h3-director): keep legacy widget order; frame_rate last

Commit 3daa486 inserted frame_rate into the required widget list at
position 5 (directly after duration). ComfyUI's positional graph loader
maps saved widgets_values arrays by index, so every save from before that
commit mis-mapped: the old ref_image_size ('match') landed on frame_rate
(crashing the queue with float('match')), the timeline JSON landed on
ref_image_size, the builder_state JSON landed on timeline_data, and the
builder_state slot stayed empty -> the Director node showed an empty
prompt on load.

Reorder the schema so the eight legacy widgets keep their original
positional order and frame_rate is appended last. Old saves now map 1:1
onto the right slots; the stale 9th value (the removed external_prompt
widget, '') lands on frame_rate instead of shifting the real fields.

Make build_guide coerce a non-numeric frame_rate to the 24.0 default
instead of raising, so the queue never crashes on a stale shifted value;
genuinely out-of-range numbers still raise.

Add regression tests: a 9-value legacy save loads without crashing and
preserves its REF2VA prompt, and the input-type order invariant is
enforced. Update the frame_rate position test to lock in the new order.
This commit is contained in:
Darksidewalker
2026-08-17 20:28:46 +02:00
parent d426b83509
commit ed5d39308d
3 changed files with 93 additions and 5 deletions
+7 -3
View File
@@ -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():
+8 -2
View File
@@ -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
@@ -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")