| from typing import Optional |
|
|
| import torch |
| from typing_extensions import override |
|
|
| from comfy_api.latest import IO, ComfyExtension |
| from comfy_api_nodes.apis.minimax import ( |
| Hailuo03AudioContent, |
| Hailuo03AudioContentUrl, |
| Hailuo03ImageContent, |
| Hailuo03ImageContentUrl, |
| Hailuo03TaskCreationRequest, |
| Hailuo03TaskCreationResponse, |
| Hailuo03TaskQueryResponse, |
| Hailuo03TextContent, |
| Hailuo03VideoContent, |
| Hailuo03VideoContentUrl, |
| MinimaxFileRetrieveResponse, |
| MiniMaxModel, |
| MinimaxTaskResultResponse, |
| MinimaxVideoGenerationRequest, |
| MinimaxVideoGenerationResponse, |
| SubjectReferenceItem, |
| ) |
| from comfy_api_nodes.util import ( |
| ApiEndpoint, |
| download_url_to_video_output, |
| poll_op, |
| sync_op, |
| upload_audio_to_comfyapi, |
| upload_images_to_comfyapi, |
| upload_video_to_comfyapi, |
| validate_image_aspect_ratio, |
| validate_image_dimensions, |
| validate_string, |
| ) |
|
|
| I2V_AVERAGE_DURATION = 114 |
| T2V_AVERAGE_DURATION = 234 |
|
|
|
|
| async def _generate_mm_video( |
| cls: type[IO.ComfyNode], |
| *, |
| prompt_text: str, |
| seed: int, |
| model: str, |
| image: Optional[torch.Tensor] = None, |
| subject: Optional[torch.Tensor] = None, |
| average_duration: Optional[int] = None, |
| ) -> IO.NodeOutput: |
| if image is None: |
| validate_string(prompt_text, field_name="prompt_text") |
| image_url = None |
| if image is not None: |
| image_url = (await upload_images_to_comfyapi(cls, image, max_images=1))[0] |
|
|
| |
| subject_reference = None |
| if subject is not None: |
| subject_url = (await upload_images_to_comfyapi(cls, subject, max_images=1))[0] |
| subject_reference = [SubjectReferenceItem(image=subject_url)] |
|
|
| response = await sync_op( |
| cls, |
| ApiEndpoint(path="/proxy/minimax/video_generation", method="POST"), |
| response_model=MinimaxVideoGenerationResponse, |
| data=MinimaxVideoGenerationRequest( |
| model=MiniMaxModel(model), |
| prompt=prompt_text, |
| callback_url=None, |
| first_frame_image=image_url, |
| subject_reference=subject_reference, |
| prompt_optimizer=None, |
| ), |
| ) |
|
|
| task_id = response.task_id |
| if not task_id: |
| raise Exception(f"MiniMax generation failed: {response.base_resp}") |
|
|
| task_result = await poll_op( |
| cls, |
| ApiEndpoint(path="/proxy/minimax/query/video_generation", query_params={"task_id": task_id}), |
| response_model=MinimaxTaskResultResponse, |
| status_extractor=lambda x: x.status.value, |
| estimated_duration=average_duration, |
| ) |
|
|
| file_id = task_result.file_id |
| if file_id is None: |
| raise Exception("Request was not successful. Missing file ID.") |
| file_result = await sync_op( |
| cls, |
| ApiEndpoint(path="/proxy/minimax/files/retrieve", query_params={"file_id": int(file_id)}), |
| response_model=MinimaxFileRetrieveResponse, |
| ) |
|
|
| file_url = file_result.file.download_url |
| if file_url is None: |
| raise Exception(f"No video was found in the response. Full response: {file_result.model_dump()}") |
| if file_result.file.backup_download_url: |
| try: |
| return IO.NodeOutput(await download_url_to_video_output(file_url, timeout=10, max_retries=2)) |
| except Exception: |
| return IO.NodeOutput( |
| await download_url_to_video_output(file_result.file.backup_download_url, max_retries=3) |
| ) |
| return IO.NodeOutput(await download_url_to_video_output(file_url)) |
|
|
|
|
| class MinimaxTextToVideoNode(IO.ComfyNode): |
| @classmethod |
| def define_schema(cls) -> IO.Schema: |
| return IO.Schema( |
| node_id="MinimaxTextToVideoNode", |
| display_name="MiniMax Text to Video", |
| category="partner/video/MiniMax", |
| description="Generates videos synchronously based on a prompt, and optional parameters.", |
| inputs=[ |
| IO.String.Input( |
| "prompt_text", |
| multiline=True, |
| default="", |
| tooltip="Text prompt to guide the video generation", |
| ), |
| IO.Combo.Input( |
| "model", |
| options=["T2V-01", "T2V-01-Director"], |
| default="T2V-01", |
| tooltip="Model to use for video generation", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=0xFFFFFFFFFFFFFFFF, |
| step=1, |
| control_after_generate=True, |
| tooltip="The random seed used for creating the noise.", |
| optional=True, |
| ), |
| ], |
| outputs=[IO.Video.Output()], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| price_badge=IO.PriceBadge( |
| expr="""{"type":"usd","usd":0.43}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| prompt_text: str, |
| model: str = "T2V-01", |
| seed: int = 0, |
| ) -> IO.NodeOutput: |
| return await _generate_mm_video( |
| cls, |
| prompt_text=prompt_text, |
| seed=seed, |
| model=model, |
| image=None, |
| subject=None, |
| average_duration=T2V_AVERAGE_DURATION, |
| ) |
|
|
|
|
| class MinimaxImageToVideoNode(IO.ComfyNode): |
| @classmethod |
| def define_schema(cls) -> IO.Schema: |
| return IO.Schema( |
| node_id="MinimaxImageToVideoNode", |
| display_name="MiniMax Image to Video", |
| category="partner/video/MiniMax", |
| description="Generates videos synchronously based on an image and prompt, and optional parameters.", |
| inputs=[ |
| IO.Image.Input( |
| "image", |
| tooltip="Image to use as first frame of video generation", |
| ), |
| IO.String.Input( |
| "prompt_text", |
| multiline=True, |
| default="", |
| tooltip="Text prompt to guide the video generation", |
| ), |
| IO.Combo.Input( |
| "model", |
| options=["I2V-01-Director", "I2V-01", "I2V-01-live"], |
| default="I2V-01", |
| tooltip="Model to use for video generation", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=0xFFFFFFFFFFFFFFFF, |
| step=1, |
| control_after_generate=True, |
| tooltip="The random seed used for creating the noise.", |
| optional=True, |
| ), |
| ], |
| outputs=[IO.Video.Output()], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| price_badge=IO.PriceBadge( |
| expr="""{"type":"usd","usd":0.43}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| image: torch.Tensor, |
| prompt_text: str, |
| model: str = "I2V-01", |
| seed: int = 0, |
| ) -> IO.NodeOutput: |
| return await _generate_mm_video( |
| cls, |
| prompt_text=prompt_text, |
| seed=seed, |
| model=model, |
| image=image, |
| subject=None, |
| average_duration=I2V_AVERAGE_DURATION, |
| ) |
|
|
|
|
| class MinimaxSubjectToVideoNode(IO.ComfyNode): |
| @classmethod |
| def define_schema(cls) -> IO.Schema: |
| return IO.Schema( |
| node_id="MinimaxSubjectToVideoNode", |
| display_name="MiniMax Subject to Video", |
| category="partner/video/MiniMax", |
| description="Generates videos synchronously based on an image and prompt, and optional parameters.", |
| inputs=[ |
| IO.Image.Input( |
| "subject", |
| tooltip="Image of subject to reference for video generation", |
| ), |
| IO.String.Input( |
| "prompt_text", |
| multiline=True, |
| default="", |
| tooltip="Text prompt to guide the video generation", |
| ), |
| IO.Combo.Input( |
| "model", |
| options=["S2V-01"], |
| default="S2V-01", |
| tooltip="Model to use for video generation", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=0xFFFFFFFFFFFFFFFF, |
| step=1, |
| control_after_generate=True, |
| tooltip="The random seed used for creating the noise.", |
| optional=True, |
| ), |
| ], |
| outputs=[IO.Video.Output()], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| subject: torch.Tensor, |
| prompt_text: str, |
| model: str = "S2V-01", |
| seed: int = 0, |
| ) -> IO.NodeOutput: |
| return await _generate_mm_video( |
| cls, |
| prompt_text=prompt_text, |
| seed=seed, |
| model=model, |
| image=None, |
| subject=subject, |
| average_duration=T2V_AVERAGE_DURATION, |
| ) |
|
|
|
|
| class MinimaxHailuoVideoNode(IO.ComfyNode): |
| @classmethod |
| def define_schema(cls) -> IO.Schema: |
| return IO.Schema( |
| node_id="MinimaxHailuoVideoNode", |
| display_name="MiniMax Hailuo 02 Video", |
| category="partner/video/MiniMax", |
| description="Generates videos from prompt, with optional start frame using the MiniMax Hailuo-02 model.", |
| inputs=[ |
| IO.String.Input( |
| "prompt_text", |
| multiline=True, |
| default="", |
| tooltip="Text prompt to guide the video generation.", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=0xFFFFFFFFFFFFFFFF, |
| step=1, |
| control_after_generate=True, |
| tooltip="The random seed used for creating the noise.", |
| optional=True, |
| ), |
| IO.Image.Input( |
| "first_frame_image", |
| tooltip="Optional image to use as the first frame to generate a video.", |
| optional=True, |
| ), |
| IO.Boolean.Input( |
| "prompt_optimizer", |
| default=True, |
| tooltip="Optimize prompt to improve generation quality when needed.", |
| optional=True, |
| ), |
| IO.Combo.Input( |
| "duration", |
| options=[6, 10], |
| default=6, |
| tooltip="The length of the output video in seconds.", |
| optional=True, |
| ), |
| IO.Combo.Input( |
| "resolution", |
| options=["768P", "1080P"], |
| default="768P", |
| tooltip="The dimensions of the video display. 1080p is 1920x1080, 768p is 1366x768.", |
| optional=True, |
| ), |
| ], |
| outputs=[IO.Video.Output()], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| price_badge=IO.PriceBadge( |
| depends_on=IO.PriceBadgeDepends(widgets=["resolution", "duration"]), |
| expr=""" |
| ( |
| $prices := { |
| "768p": {"6": 0.28, "10": 0.56}, |
| "1080p": {"6": 0.49} |
| }; |
| $resPrices := $lookup($prices, $lowercase(widgets.resolution)); |
| $price := $lookup($resPrices, $string(widgets.duration)); |
| {"type":"usd","usd": $price ? $price : 0.43} |
| ) |
| """, |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| prompt_text: str, |
| seed: int = 0, |
| first_frame_image: Optional[torch.Tensor] = None, |
| prompt_optimizer: bool = True, |
| duration: int = 6, |
| resolution: str = "768P", |
| model: str = "MiniMax-Hailuo-02", |
| ) -> IO.NodeOutput: |
| if first_frame_image is None: |
| validate_string(prompt_text, field_name="prompt_text") |
|
|
| if model == "MiniMax-Hailuo-02" and resolution.upper() == "1080P" and duration != 6: |
| raise Exception( |
| "When model is MiniMax-Hailuo-02 and resolution is 1080P, duration is limited to 6 seconds." |
| ) |
|
|
| |
| image_url = None |
| if first_frame_image is not None: |
| image_url = (await upload_images_to_comfyapi(cls, first_frame_image, max_images=1))[0] |
|
|
| response = await sync_op( |
| cls, |
| ApiEndpoint(path="/proxy/minimax/video_generation", method="POST"), |
| response_model=MinimaxVideoGenerationResponse, |
| data=MinimaxVideoGenerationRequest( |
| model=MiniMaxModel(model), |
| prompt=prompt_text, |
| callback_url=None, |
| first_frame_image=image_url, |
| prompt_optimizer=prompt_optimizer, |
| duration=duration, |
| resolution=resolution, |
| ), |
| ) |
|
|
| task_id = response.task_id |
| if not task_id: |
| raise Exception(f"MiniMax generation failed: {response.base_resp}") |
|
|
| average_duration = 120 if resolution == "768P" else 240 |
| task_result = await poll_op( |
| cls, |
| ApiEndpoint(path="/proxy/minimax/query/video_generation", query_params={"task_id": task_id}), |
| response_model=MinimaxTaskResultResponse, |
| status_extractor=lambda x: x.status.value, |
| estimated_duration=average_duration, |
| ) |
|
|
| file_id = task_result.file_id |
| if file_id is None: |
| raise Exception("Request was not successful. Missing file ID.") |
| file_result = await sync_op( |
| cls, |
| ApiEndpoint(path="/proxy/minimax/files/retrieve", query_params={"file_id": int(file_id)}), |
| response_model=MinimaxFileRetrieveResponse, |
| ) |
|
|
| file_url = file_result.file.download_url |
| if file_url is None: |
| raise Exception(f"No video was found in the response. Full response: {file_result.model_dump()}") |
|
|
| if file_result.file.backup_download_url: |
| try: |
| return IO.NodeOutput(await download_url_to_video_output(file_url, timeout=10, max_retries=2)) |
| except Exception: |
| return IO.NodeOutput( |
| await download_url_to_video_output(file_result.file.backup_download_url, max_retries=3) |
| ) |
| return IO.NodeOutput(await download_url_to_video_output(file_url)) |
|
|
|
|
| HAILUO_03_CREATE_ENDPOINT = "/proxy/minimax/v2/video_generation" |
| HAILUO_03_QUERY_ENDPOINT = "/proxy/minimax/v2/query/video_generation" |
| HAILUO_03_MODELS = {"MiniMax H3": "MiniMax-H3"} |
| HAILUO_03_FAILED_STATUSES = ["failed", "cancelled", "expired"] |
|
|
|
|
| def _hailuo03_model_inputs(include_ratio: bool = True, allow_adaptive: bool = True): |
| inputs = [ |
| IO.String.Input( |
| "prompt", |
| multiline=True, |
| default="", |
| tooltip="Text prompt for video generation.", |
| ), |
| IO.Combo.Input( |
| "resolution", |
| options=["768P", "2K"], |
| tooltip="Resolution of the output video.", |
| ), |
| ] |
| if include_ratio: |
| ratio_options = ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"] |
| if allow_adaptive: |
| ratio_options.insert(0, "adaptive") |
| inputs.append( |
| IO.Combo.Input( |
| "ratio", |
| options=ratio_options, |
| default=ratio_options[0], |
| tooltip="Aspect ratio of the output video.", |
| ) |
| ) |
| inputs.append( |
| 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, |
| ) |
| ) |
| return inputs |
|
|
|
|
| async def _hailuo03_run_task( |
| cls: type[IO.ComfyNode], |
| *, |
| model_id: str, |
| content: list, |
| resolution: str, |
| duration: int, |
| ratio: str | None, |
| seed: int, |
| watermark: bool, |
| ) -> IO.NodeOutput: |
| response = await sync_op( |
| cls, |
| ApiEndpoint(path=HAILUO_03_CREATE_ENDPOINT, method="POST"), |
| response_model=Hailuo03TaskCreationResponse, |
| data=Hailuo03TaskCreationRequest( |
| model=model_id, |
| content=content, |
| resolution=resolution, |
| duration=duration, |
| ratio=ratio, |
| seed=seed, |
| aigc_watermark=watermark, |
| ), |
| ) |
| task_result = await poll_op( |
| cls, |
| ApiEndpoint(path=f"{HAILUO_03_QUERY_ENDPOINT}/{response.task_id}"), |
| response_model=Hailuo03TaskQueryResponse, |
| status_extractor=lambda r: r.task.status, |
| failed_statuses=HAILUO_03_FAILED_STATUSES, |
| poll_interval=15, |
| ) |
| video_url = task_result.task.content.url if task_result.task.content else None |
| if not video_url: |
| raise Exception(f"No video URL in the response: {task_result.model_dump()}") |
| return IO.NodeOutput(await download_url_to_video_output(video_url)) |
|
|
|
|
| class MinimaxHailuo03TextToVideoNode(IO.ComfyNode): |
| @classmethod |
| def define_schema(cls): |
| return IO.Schema( |
| 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.", |
| inputs=[ |
| IO.DynamicCombo.Input( |
| "model", |
| options=[IO.DynamicCombo.Option("MiniMax H3", _hailuo03_model_inputs(allow_adaptive=False))], |
| tooltip="Model to use for video generation.", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=42, |
| min=0, |
| max=4294967295, |
| step=1, |
| display_mode=IO.NumberDisplay.number, |
| control_after_generate=True, |
| tooltip="Random seed. The same request with the same seed gives similar, " |
| "but not guaranteed identical, results.", |
| ), |
| IO.Boolean.Input( |
| "watermark", |
| default=False, |
| tooltip="Whether to add an AIGC watermark to the video.", |
| advanced=True, |
| ), |
| ], |
| outputs=[ |
| IO.Video.Output(), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| price_badge=IO.PriceBadge( |
| depends_on=IO.PriceBadgeDepends(widgets=["model.resolution", "model.duration"]), |
| expr=""" |
| ( |
| $dur := $lookup(widgets, "model.duration"); |
| $rate := $lookup(widgets, "model.resolution") = "768p" ? 0.1287 : 0.1859; |
| {"type": "usd", "usd": $dur * $rate} |
| ) |
| """, |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| model: dict, |
| seed: int, |
| watermark: bool, |
| ) -> IO.NodeOutput: |
| validate_string(model["prompt"], strip_whitespace=True, min_length=1) |
| return await _hailuo03_run_task( |
| cls, |
| model_id=HAILUO_03_MODELS[model["model"]], |
| content=[Hailuo03TextContent(text=model["prompt"])], |
| resolution=model["resolution"], |
| duration=model["duration"], |
| ratio=model["ratio"], |
| seed=seed, |
| watermark=watermark, |
| ) |
|
|
|
|
| class MinimaxHailuo03FirstLastFrameNode(IO.ComfyNode): |
| @classmethod |
| def define_schema(cls): |
| return IO.Schema( |
| node_id="MinimaxHailuo03FirstLastFrameNode", |
| 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.", |
| inputs=[ |
| IO.DynamicCombo.Input( |
| "model", |
| options=[IO.DynamicCombo.Option("MiniMax H3", _hailuo03_model_inputs(include_ratio=False))], |
| tooltip="Model to use for video generation.", |
| ), |
| IO.Image.Input( |
| "first_frame", |
| tooltip="First frame image for the video.", |
| ), |
| IO.Image.Input( |
| "last_frame", |
| tooltip="Optional last frame image for the video.", |
| optional=True, |
| ), |
| IO.Int.Input( |
| "seed", |
| default=42, |
| min=0, |
| max=4294967295, |
| step=1, |
| display_mode=IO.NumberDisplay.number, |
| control_after_generate=True, |
| tooltip="Random seed. The same request with the same seed gives similar, " |
| "but not guaranteed identical, results.", |
| ), |
| IO.Boolean.Input( |
| "watermark", |
| default=False, |
| tooltip="Whether to add an AIGC watermark to the video.", |
| advanced=True, |
| ), |
| ], |
| outputs=[ |
| IO.Video.Output(), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| price_badge=IO.PriceBadge( |
| depends_on=IO.PriceBadgeDepends(widgets=["model.resolution", "model.duration"]), |
| expr=""" |
| ( |
| $dur := $lookup(widgets, "model.duration"); |
| $rate := $lookup(widgets, "model.resolution") = "768p" ? 0.1287 : 0.1859; |
| {"type": "usd", "usd": $dur * $rate} |
| ) |
| """, |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| model: dict, |
| first_frame: torch.Tensor, |
| seed: int, |
| watermark: bool, |
| last_frame: torch.Tensor | None = None, |
| ) -> IO.NodeOutput: |
| validate_string(model["prompt"], strip_whitespace=True, min_length=1) |
| for frame in (first_frame, last_frame): |
| if frame is not None: |
| validate_image_aspect_ratio(frame, (2, 5), (5, 2), strict=False) |
| validate_image_dimensions(frame, min_width=256, min_height=256) |
|
|
| content: list = [ |
| Hailuo03TextContent(text=model["prompt"]), |
| Hailuo03ImageContent( |
| image_url=Hailuo03ImageContentUrl( |
| url=( |
| await upload_images_to_comfyapi( |
| cls, first_frame, max_images=1, wait_label="Uploading first frame" |
| ) |
| )[0], |
| ), |
| role="first_frame", |
| ), |
| ] |
| if last_frame is not None: |
| content.append( |
| Hailuo03ImageContent( |
| image_url=Hailuo03ImageContentUrl( |
| url=( |
| await upload_images_to_comfyapi( |
| cls, last_frame, max_images=1, wait_label="Uploading last frame" |
| ) |
| )[0], |
| ), |
| role="last_frame", |
| ) |
| ) |
| return await _hailuo03_run_task( |
| cls, |
| model_id=HAILUO_03_MODELS[model["model"]], |
| content=content, |
| resolution=model["resolution"], |
| duration=model["duration"], |
| ratio=None, |
| seed=seed, |
| watermark=watermark, |
| ) |
|
|
|
|
| class MinimaxHailuo03ReferenceNode(IO.ComfyNode): |
| @classmethod |
| def define_schema(cls): |
| return IO.Schema( |
| node_id="MinimaxHailuo03ReferenceNode", |
| display_name="MiniMax H3 Reference to Video", |
| category="partner/video/MiniMax", |
| description="Generate video conditioned on reference images, videos, and audio using the " |
| "MiniMax H3 model. Refer to the references in the prompt by their order: " |
| "'Image 1', 'Image 2', 'Video 1', 'Audio 1', and so on.", |
| inputs=[ |
| IO.DynamicCombo.Input( |
| "model", |
| options=[ |
| IO.DynamicCombo.Option( |
| "MiniMax H3", |
| [ |
| *_hailuo03_model_inputs(), |
| IO.Autogrow.Input( |
| "reference_images", |
| template=IO.Autogrow.TemplateNames( |
| IO.Image.Input("reference_image"), |
| names=[ |
| "image_1", |
| "image_2", |
| "image_3", |
| "image_4", |
| "image_5", |
| "image_6", |
| "image_7", |
| "image_8", |
| "image_9", |
| ], |
| min=0, |
| ), |
| tooltip="Subject or style reference images, referred to in the prompt " |
| "as 'Image 1'..'Image 9' in connection order. Up to 9 images.", |
| ), |
| IO.Autogrow.Input( |
| "reference_videos", |
| template=IO.Autogrow.TemplateNames( |
| IO.Video.Input("reference_video"), |
| names=["video_1", "video_2", "video_3"], |
| min=0, |
| ), |
| tooltip="Motion or scene reference videos, referred to in the prompt " |
| "as 'Video 1'..'Video 3' in connection order. Up to 3 videos, " |
| "2-15 seconds each, 15 seconds in total.", |
| ), |
| IO.Autogrow.Input( |
| "reference_audios", |
| template=IO.Autogrow.TemplateNames( |
| IO.Audio.Input("reference_audio"), |
| names=["audio_1", "audio_2", "audio_3"], |
| min=0, |
| ), |
| tooltip="Audio references, referred to in the prompt as " |
| "'Audio 1'..'Audio 3' in connection order. Up to 3 clips, " |
| "2-15 seconds each, 15 seconds in total. Cannot be used without " |
| "a reference image or video.", |
| ), |
| ], |
| ) |
| ], |
| tooltip="Model to use for video generation.", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=42, |
| min=0, |
| max=4294967295, |
| step=1, |
| display_mode=IO.NumberDisplay.number, |
| control_after_generate=True, |
| tooltip="Random seed. The same request with the same seed gives similar, " |
| "but not guaranteed identical, results.", |
| ), |
| IO.Boolean.Input( |
| "watermark", |
| default=False, |
| tooltip="Whether to add an AIGC watermark to the video.", |
| advanced=True, |
| ), |
| ], |
| outputs=[ |
| IO.Video.Output(), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| price_badge=IO.PriceBadge( |
| depends_on=IO.PriceBadgeDepends( |
| widgets=["model.resolution", "model.duration"], |
| input_groups=["model.reference_images", "model.reference_videos"], |
| ), |
| expr=""" |
| ( |
| $dur := $lookup(widgets, "model.duration"); |
| $rate := $lookup(widgets, "model.resolution") = "768p" ? 0.1287 : 0.1859; |
| $imgsRaw := $lookup(inputGroups, "model.reference_images"); |
| $imgs := $imgsRaw ? $imgsRaw : 0; |
| $vidsRaw := $lookup(inputGroups, "model.reference_videos"); |
| $vids := $vidsRaw ? $vidsRaw : 0; |
| $base := $dur * $rate + ($imgs > 5 ? ($imgs - 5) * 0.0572 : 0); |
| $vids > 0 |
| ? {"type": "range_usd", "min_usd": $base + $vids * 2 * $rate, |
| "max_usd": $base + 15 * $rate, "format": {"approximate": true}} |
| : {"type": "usd", "usd": $base} |
| ) |
| """, |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| model: dict, |
| seed: int, |
| watermark: bool, |
| ) -> IO.NodeOutput: |
| validate_string(model["prompt"], strip_whitespace=True, min_length=1) |
|
|
| reference_images = model.get("reference_images", {}) |
| reference_videos = model.get("reference_videos", {}) |
| reference_audios = model.get("reference_audios", {}) |
| if not reference_images and not reference_videos: |
| raise ValueError("At least one reference image or video is required.") |
|
|
| for image in reference_images.values(): |
| validate_image_aspect_ratio(image, (2, 5), (5, 2), strict=False) |
| validate_image_dimensions(image, min_width=256, min_height=256) |
|
|
| total_video_duration = 0.0 |
| for i, video in enumerate(reference_videos.values(), 1): |
| try: |
| fps = float(video.get_frame_rate()) |
| except Exception: |
| fps = 0.0 |
| if fps and not (23.9 <= fps <= 60.5): |
| raise ValueError(f"Reference video {i} is {fps:.2f} FPS. Supported range is 23.976-60 FPS.") |
| try: |
| dur = video.get_duration() |
| except Exception: |
| continue |
| if dur < 1.8: |
| raise ValueError(f"Reference video {i} is too short: {dur:.1f}s. Minimum duration is 2 seconds.") |
| total_video_duration += dur |
| if total_video_duration > 15.1: |
| raise ValueError( |
| f"Total reference video duration is {total_video_duration:.1f}s. Maximum is 15 seconds." |
| ) |
|
|
| total_audio_duration = 0.0 |
| for i, audio in enumerate(reference_audios.values(), 1): |
| dur = int(audio["waveform"].shape[-1]) / int(audio["sample_rate"]) |
| if dur < 1.8: |
| raise ValueError(f"Reference audio {i} is too short: {dur:.1f}s. Minimum duration is 2 seconds.") |
| total_audio_duration += dur |
| if total_audio_duration > 15.1: |
| raise ValueError( |
| f"Total reference audio duration is {total_audio_duration:.1f}s. Maximum is 15 seconds." |
| ) |
|
|
| content: list = [Hailuo03TextContent(text=model["prompt"])] |
| for i, image in enumerate(reference_images.values(), 1): |
| content.append( |
| Hailuo03ImageContent( |
| image_url=Hailuo03ImageContentUrl( |
| url=( |
| await upload_images_to_comfyapi( |
| cls, image, max_images=1, wait_label=f"Uploading image {i}" |
| ) |
| )[0], |
| ), |
| role="reference_image", |
| ) |
| ) |
| for i, video in enumerate(reference_videos.values(), 1): |
| content.append( |
| Hailuo03VideoContent( |
| video_url=Hailuo03VideoContentUrl( |
| url=await upload_video_to_comfyapi(cls, video, wait_label=f"Uploading video {i}"), |
| ), |
| ) |
| ) |
| for audio in reference_audios.values(): |
| content.append( |
| Hailuo03AudioContent( |
| audio_url=Hailuo03AudioContentUrl( |
| url=await upload_audio_to_comfyapi( |
| cls, |
| audio, |
| container_format="mp3", |
| codec_name="libmp3lame", |
| mime_type="audio/mpeg", |
| ), |
| ), |
| ) |
| ) |
| return await _hailuo03_run_task( |
| cls, |
| model_id=HAILUO_03_MODELS[model["model"]], |
| content=content, |
| resolution=model["resolution"], |
| duration=model["duration"], |
| ratio=model["ratio"], |
| seed=seed, |
| watermark=watermark, |
| ) |
|
|
|
|
| class MinimaxExtension(ComfyExtension): |
| @override |
| async def get_node_list(self) -> list[type[IO.ComfyNode]]: |
| return [ |
| MinimaxTextToVideoNode, |
| MinimaxImageToVideoNode, |
| |
| MinimaxHailuoVideoNode, |
| MinimaxHailuo03TextToVideoNode, |
| MinimaxHailuo03FirstLastFrameNode, |
| MinimaxHailuo03ReferenceNode, |
| ] |
|
|
|
|
| async def comfy_entrypoint() -> MinimaxExtension: |
| return MinimaxExtension() |
|
|