From 624f56565b55ecea83a724683818c1beb368e796 Mon Sep 17 00:00:00 2001 From: Purz <97489706+purzbeats@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:58:21 -0400 Subject: [PATCH] [Partner Nodes] feat(MiniMax): add H3 Max option to H3 text-to-video and first-last-frame nodes (#16025) * [Partner Nodes] feat(MiniMax): add H3 Max option to H3 text-to-video and first-last-frame nodes Signed-off-by: Alexander Piskun * [Partner Nodes] fix(downscale): never make a downscaled image more elongated than its source Signed-off-by: Alexander Piskun * [Partner Nodes] fix(M3-Max): add check fpr max prompt length Signed-off-by: Alexander Piskun * [Partner Nodes] chore(Minimax): correct route name Signed-off-by: bigcat88 --------- Signed-off-by: Alexander Piskun Signed-off-by: bigcat88 Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Co-authored-by: Alexander Piskun --- comfy_api_nodes/apis/minimax.py | 32 ++++ comfy_api_nodes/nodes_minimax.py | 152 +++++++++++++++++- comfy_api_nodes/util/conversions.py | 18 +-- .../image_conversions_test.py | 50 +++++- 4 files changed, 234 insertions(+), 18 deletions(-) diff --git a/comfy_api_nodes/apis/minimax.py b/comfy_api_nodes/apis/minimax.py index 12a0853ac..e7c128f25 100644 --- a/comfy_api_nodes/apis/minimax.py +++ b/comfy_api_nodes/apis/minimax.py @@ -215,3 +215,35 @@ class Hailuo03Task(BaseModel): class Hailuo03TaskQueryResponse(BaseModel): task: Hailuo03Task = Field(...) + + +class Hailuo03MaxTaskCreationResponse(BaseModel): + request_id: str = Field(...) + status: str | None = Field(None) + + +class Hailuo03MaxTaskStatusResponse(BaseModel): + status: str | None = Field(None) + + +class Hailuo03MaxVideoFile(BaseModel): + url: str = Field(...) + content_type: str | None = Field(None) + file_name: str | None = Field(None) + file_size: int | None = Field(None) + + +class Hailuo03MaxVideoRequest(BaseModel): + prompt: str = Field(...) + duration: int = Field(..., ge=5, le=15) + resolution: str = Field(...) + prompt_expansion_mode: str = Field(...) + seed: int = Field(...) + aspect_ratio: str | None = Field(None) + image_url: str | None = Field(None) + end_image_url: str | None = Field(None) + + +class Hailuo03MaxVideoResult(BaseModel): + video: Hailuo03MaxVideoFile = Field(...) + expanded_prompt: str | None = Field(None) diff --git a/comfy_api_nodes/nodes_minimax.py b/comfy_api_nodes/nodes_minimax.py index de3895221..3663d7ec0 100644 --- a/comfy_api_nodes/nodes_minimax.py +++ b/comfy_api_nodes/nodes_minimax.py @@ -10,6 +10,10 @@ from comfy_api_nodes.apis.minimax import ( Hailuo03ContextIRRequest, Hailuo03ImageContent, Hailuo03ImageContentUrl, + Hailuo03MaxTaskCreationResponse, + Hailuo03MaxTaskStatusResponse, + Hailuo03MaxVideoRequest, + Hailuo03MaxVideoResult, Hailuo03RegenerationRequest, Hailuo03TaskCreationRequest, Hailuo03TaskCreationResponse, @@ -461,6 +465,10 @@ HAILUO_03_FAILED_STATUSES = ["failed", "cancelled", "expired"] HAILUO_03_CONTEXT_IR_ENDPOINT = "/proxy/minimax/v2/h3_context_ir" HAILUO_03_REGENERATION_ENDPOINT = "/proxy/minimax/v2/video_regeneration" +HAILUO_03_MAX_MODEL = "MiniMax H3 Max" +HAILUO_03_MAX_ENDPOINT = "/proxy/fal/minimax/h3-max" +HAILUO_03_MAX_PROMPT_MAX_LENGTH = 50000 + def _hailuo03_model_inputs(include_ratio: bool = True, allow_adaptive: bool = True): inputs = [ @@ -541,6 +549,81 @@ async def _hailuo03_run_task( return IO.NodeOutput(await download_url_to_video_output(video_url)) +def _hailuo03_max_model_inputs(include_ratio: bool = True): + inputs = [ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Text prompt for video generation.", + ), + IO.Combo.Input( + "resolution", + options=["480P", "768P"], + default="768P", + tooltip="Resolution of the output video.", + ), + ] + if include_ratio: + inputs.append( + IO.Combo.Input( + "ratio", + options=["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"], + default="16:9", + tooltip="Aspect ratio of the output video.", + ) + ) + inputs.extend( + [ + IO.Int.Input( + "duration", + default=5, + min=5, + max=15, + step=1, + tooltip="Duration of the output video in seconds (5-15).", + display_mode=IO.NumberDisplay.slider, + ), + IO.Combo.Input( + "prompt_expansion_mode", + options=["balanced", "quality"], + default="balanced", + tooltip="How much effort is spent rewriting the prompt before generation.", + ), + ] + ) + return inputs + + +async def _hailuo03_max_run_task( + cls: type[IO.ComfyNode], + *, + endpoint: str, + request: Hailuo03MaxVideoRequest, +) -> IO.NodeOutput: + submit = await sync_op( + cls, + ApiEndpoint(path=f"{HAILUO_03_MAX_ENDPOINT}/{endpoint}", method="POST"), + response_model=Hailuo03MaxTaskCreationResponse, + data=request, + ) + await poll_op( + cls, + ApiEndpoint(path=f"{HAILUO_03_MAX_ENDPOINT}/requests/{submit.request_id}/status"), + response_model=Hailuo03MaxTaskStatusResponse, + status_extractor=lambda r: r.status, + completed_statuses=["COMPLETED"], + queued_statuses=["IN_QUEUE"], + poll_interval=5, + ) + result = await sync_op( + cls, + ApiEndpoint(path=f"{HAILUO_03_MAX_ENDPOINT}/requests/{submit.request_id}"), + response_model=Hailuo03MaxVideoResult, + ) + return IO.NodeOutput(await download_url_to_video_output(result.video.url)) + + class MinimaxHailuo03TextToVideoNode(IO.ComfyNode): @classmethod def define_schema(cls): @@ -548,11 +631,14 @@ class MinimaxHailuo03TextToVideoNode(IO.ComfyNode): node_id="MinimaxHailuo03TextToVideoNode", display_name="MiniMax H3 Text to Video", category="partner/video/MiniMax", - description="Generate video from a text prompt using the MiniMax H3 model.", + description="Generate video from a text prompt using the MiniMax H3 models.", inputs=[ IO.DynamicCombo.Input( "model", - options=[IO.DynamicCombo.Option("MiniMax H3", _hailuo03_model_inputs(allow_adaptive=False))], + options=[ + IO.DynamicCombo.Option("MiniMax H3", _hailuo03_model_inputs(allow_adaptive=False)), + IO.DynamicCombo.Option(HAILUO_03_MAX_MODEL, _hailuo03_max_model_inputs()), + ], tooltip="Model to use for video generation.", ), IO.Int.Input( @@ -583,11 +669,14 @@ class MinimaxHailuo03TextToVideoNode(IO.ComfyNode): ], is_api_node=True, price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["model.resolution", "model.duration"]), + depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution", "model.duration"]), expr=""" ( $dur := $lookup(widgets, "model.duration"); - $rate := $lookup(widgets, "model.resolution") = "768p" ? 0.1287 : 0.1859; + $res := $lookup(widgets, "model.resolution"); + $rate := $lookup(widgets, "model") = "minimax h3 max" + ? ($res = "480p" ? 0.0715 : 0.1144) + : ($res = "768p" ? 0.1287 : 0.1859); {"type": "usd", "usd": $dur * $rate} ) """, @@ -602,6 +691,22 @@ class MinimaxHailuo03TextToVideoNode(IO.ComfyNode): watermark: bool, ) -> IO.NodeOutput: validate_string(model["prompt"], strip_whitespace=True, min_length=1) + if model["model"] == HAILUO_03_MAX_MODEL: + if watermark: + raise ValueError("Watermark is only supported by MiniMax H3.") + validate_string(model["prompt"], strip_whitespace=False, max_length=HAILUO_03_MAX_PROMPT_MAX_LENGTH) + return await _hailuo03_max_run_task( + cls, + endpoint="text-to-video", + request=Hailuo03MaxVideoRequest( + prompt=model["prompt"], + duration=model["duration"], + resolution=model["resolution"], + prompt_expansion_mode=model["prompt_expansion_mode"], + seed=seed, + aspect_ratio=model["ratio"], + ), + ) return await _hailuo03_run_task( cls, model_id=HAILUO_03_MODELS[model["model"]], @@ -622,11 +727,14 @@ class MinimaxHailuo03FirstLastFrameNode(IO.ComfyNode): display_name="MiniMax H3 First-Last-Frame to Video", category="partner/video/MiniMax", description="Generate video from a first frame image and an optional last frame image " - "using the MiniMax H3 model. The aspect ratio of the video follows the supplied images.", + "using the MiniMax H3 models. The aspect ratio of the video follows the supplied images.", inputs=[ IO.DynamicCombo.Input( "model", - options=[IO.DynamicCombo.Option("MiniMax H3", _hailuo03_model_inputs(include_ratio=False))], + options=[ + IO.DynamicCombo.Option("MiniMax H3", _hailuo03_model_inputs(include_ratio=False)), + IO.DynamicCombo.Option(HAILUO_03_MAX_MODEL, _hailuo03_max_model_inputs(include_ratio=False)), + ], tooltip="Model to use for video generation.", ), IO.Image.Input( @@ -666,11 +774,14 @@ class MinimaxHailuo03FirstLastFrameNode(IO.ComfyNode): ], is_api_node=True, price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["model.resolution", "model.duration"]), + depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution", "model.duration"]), expr=""" ( $dur := $lookup(widgets, "model.duration"); - $rate := $lookup(widgets, "model.resolution") = "768p" ? 0.1287 : 0.1859; + $res := $lookup(widgets, "model.resolution"); + $rate := $lookup(widgets, "model") = "minimax h3 max" + ? ($res = "480p" ? 0.0715 : 0.1144) + : ($res = "768p" ? 0.1287 : 0.1859); {"type": "usd", "usd": $dur * $rate} ) """, @@ -691,6 +802,31 @@ class MinimaxHailuo03FirstLastFrameNode(IO.ComfyNode): if frame is not None: validate_image_aspect_ratio(frame, (2, 5), (5, 2), strict=False) # 0.4 to 2.5 validate_image_dimensions(frame, min_width=256, min_height=256) + if model["model"] == HAILUO_03_MAX_MODEL: + if watermark: + raise ValueError("Watermark is only supported by MiniMax H3.") + validate_string(model["prompt"], strip_whitespace=False, max_length=HAILUO_03_MAX_PROMPT_MAX_LENGTH) + image_url = ( + await upload_images_to_comfyapi(cls, first_frame, max_images=1, wait_label="Uploading first frame") + )[0] + end_image_url = None + if last_frame is not None: + end_image_url = ( + await upload_images_to_comfyapi(cls, last_frame, max_images=1, wait_label="Uploading last frame") + )[0] + return await _hailuo03_max_run_task( + cls, + endpoint="image-to-video", + request=Hailuo03MaxVideoRequest( + prompt=model["prompt"], + duration=model["duration"], + resolution=model["resolution"], + prompt_expansion_mode=model["prompt_expansion_mode"], + seed=seed, + image_url=image_url, + end_image_url=end_image_url, + ), + ) content: list = [ Hailuo03TextContent(text=model["prompt"]), diff --git a/comfy_api_nodes/util/conversions.py b/comfy_api_nodes/util/conversions.py index eb81447a0..80c36eaff 100644 --- a/comfy_api_nodes/util/conversions.py +++ b/comfy_api_nodes/util/conversions.py @@ -147,25 +147,25 @@ def pil_to_bytesio(img: Image.Image, mime_type: str = "image/png") -> BytesIO: def _compute_downscale_dims(src_w: int, src_h: int, total_pixels: int) -> tuple[int, int] | None: """Return downscaled (w, h) with even dims fitting ``total_pixels``, or None if already fits. - Source aspect ratio is preserved; output may drift by a fraction of a percent because both dimensions - are rounded down to even values (many codecs require divisible-by-2). + Both dimensions are rounded to even values (many codecs require divisible-by-2). """ pixels = src_w * src_h if pixels <= total_pixels: return None scale = math.sqrt(total_pixels / pixels) - new_w = max(2, int(src_w * scale)) - new_h = max(2, int(src_h * scale)) - new_w -= new_w % 2 - new_h -= new_h % 2 - return new_w, new_h + long_src, short_src = max(src_w, src_h), min(src_w, src_h) + long_new = max(2, int(long_src * scale) // 2 * 2) + short_new = max(2, math.ceil(long_new * short_src / long_src / 2) * 2) + if long_new * short_new > total_pixels: + long_new = max(2, total_pixels // short_new // 2 * 2) + short_new = max(2, math.ceil(long_new * short_src / long_src / 2) * 2) + return (long_new, short_new) if src_w >= src_h else (short_new, long_new) def downscale_image_tensor(image: torch.Tensor, total_pixels: int = 1536 * 1024) -> torch.Tensor: """Downscale input image tensor to roughly the specified total pixels. - Output dimensions are rounded down to even values so that the result is guaranteed to fit within ``total_pixels`` - and is compatible with codecs that require even dimensions (e.g. yuv420p). + Output dimensions are even and guaranteed to fit within ``total_pixels`` """ samples = image.movedim(-1, 1) dims = _compute_downscale_dims(samples.shape[3], samples.shape[2], int(total_pixels)) diff --git a/tests-unit/comfy_api_nodes_test/image_conversions_test.py b/tests-unit/comfy_api_nodes_test/image_conversions_test.py index 3caff2818..adfcaa886 100644 --- a/tests-unit/comfy_api_nodes_test/image_conversions_test.py +++ b/tests-unit/comfy_api_nodes_test/image_conversions_test.py @@ -9,7 +9,11 @@ from comfy.cli_args import args if not torch.cuda.is_available(): args.cpu = True -from comfy_api_nodes.util.conversions import bytesio_to_image_tensor, pad_images_to_common_channels # noqa: E402 +from comfy_api_nodes.util.conversions import ( # noqa: E402 + bytesio_to_image_tensor, + downscale_image_tensor, + pad_images_to_common_channels, +) def encode(image: Image.Image, image_format: str = "PNG") -> BytesIO: @@ -78,3 +82,47 @@ def test_pad_leaves_homogeneous_channels_unchanged(): images = [torch.rand(1, 4, 4, 3), torch.rand(2, 4, 4, 3)] padded = pad_images_to_common_channels(images) assert all(p is i for p, i in zip(padded, images)) + + +DOWNSCALE_CASES = [ + (5000, 2000, 2048 * 2048), + (2000, 5000, 2048 * 2048), + (4096, 1638, 2048 * 2048), + (1000, 400, 128 * 128), + (400, 1000, 128 * 128), + (999, 333, 100 * 100), + (333, 999, 100 * 100), + (3000, 3000, 256 * 256), +] + + +def downscaled_size(width, height, total_pixels): + out = downscale_image_tensor(torch.zeros(1, height, width, 3), total_pixels=total_pixels) + return out.shape[2], out.shape[1] + + +@pytest.mark.parametrize("width, height, total_pixels", DOWNSCALE_CASES) +def test_downscale_dims_are_even(width, height, total_pixels): + new_w, new_h = downscaled_size(width, height, total_pixels) + assert new_w % 2 == 0 and new_h % 2 == 0 + + +@pytest.mark.parametrize("width, height, total_pixels", DOWNSCALE_CASES) +def test_downscale_fits_total_pixels(width, height, total_pixels): + new_w, new_h = downscaled_size(width, height, total_pixels) + assert new_w * new_h <= total_pixels + + +@pytest.mark.parametrize("width, height, total_pixels", DOWNSCALE_CASES) +def test_downscale_never_makes_aspect_more_elongated(width, height, total_pixels): + new_w, new_h = downscaled_size(width, height, total_pixels) + src_ratio, new_ratio = width / height, new_w / new_h + if src_ratio >= 1: + assert 1 <= new_ratio <= src_ratio + else: + assert src_ratio <= new_ratio <= 1 + + +def test_downscale_leaves_fitting_images_untouched(): + image = torch.zeros(1, 300, 700, 3) + assert downscale_image_tensor(image, total_pixels=700 * 300) is image