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:
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user