| from inspect import cleandoc
|
| from typing import Optional
|
| import logging
|
| import torch
|
|
|
| from typing_extensions import override
|
| from comfy_api.latest import ComfyExtension, io as comfy_io
|
| from comfy_api.input_impl.video_types import VideoFromFile
|
| from comfy_api_nodes.apis import (
|
| MinimaxVideoGenerationRequest,
|
| MinimaxVideoGenerationResponse,
|
| MinimaxFileRetrieveResponse,
|
| MinimaxTaskResultResponse,
|
| SubjectReferenceItem,
|
| MiniMaxModel,
|
| )
|
| from comfy_api_nodes.apis.client import (
|
| ApiEndpoint,
|
| HttpMethod,
|
| SynchronousOperation,
|
| PollingOperation,
|
| EmptyRequest,
|
| )
|
| from comfy_api_nodes.apinode_utils import (
|
| download_url_to_bytesio,
|
| upload_images_to_comfyapi,
|
| validate_string,
|
| )
|
| from server import PromptServer
|
|
|
|
|
| I2V_AVERAGE_DURATION = 114
|
| T2V_AVERAGE_DURATION = 234
|
|
|
|
|
| async def _generate_mm_video(
|
| *,
|
| auth: dict[str, str],
|
| node_id: str,
|
| prompt_text: str,
|
| seed: int,
|
| model: str,
|
| image: Optional[torch.Tensor] = None,
|
| subject: Optional[torch.Tensor] = None,
|
| average_duration: Optional[int] = None,
|
| ) -> comfy_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(image, max_images=1, auth_kwargs=auth))[0]
|
|
|
|
|
| subject_reference = None
|
| if subject is not None:
|
| subject_url = (await upload_images_to_comfyapi(subject, max_images=1, auth_kwargs=auth))[0]
|
| subject_reference = [SubjectReferenceItem(image=subject_url)]
|
|
|
|
|
| video_generate_operation = SynchronousOperation(
|
| endpoint=ApiEndpoint(
|
| path="/proxy/minimax/video_generation",
|
| method=HttpMethod.POST,
|
| request_model=MinimaxVideoGenerationRequest,
|
| response_model=MinimaxVideoGenerationResponse,
|
| ),
|
| request=MinimaxVideoGenerationRequest(
|
| model=MiniMaxModel(model),
|
| prompt=prompt_text,
|
| callback_url=None,
|
| first_frame_image=image_url,
|
| subject_reference=subject_reference,
|
| prompt_optimizer=None,
|
| ),
|
| auth_kwargs=auth,
|
| )
|
| response = await video_generate_operation.execute()
|
|
|
| task_id = response.task_id
|
| if not task_id:
|
| raise Exception(f"MiniMax generation failed: {response.base_resp}")
|
|
|
| video_generate_operation = PollingOperation(
|
| poll_endpoint=ApiEndpoint(
|
| path="/proxy/minimax/query/video_generation",
|
| method=HttpMethod.GET,
|
| request_model=EmptyRequest,
|
| response_model=MinimaxTaskResultResponse,
|
| query_params={"task_id": task_id},
|
| ),
|
| completed_statuses=["Success"],
|
| failed_statuses=["Fail"],
|
| status_extractor=lambda x: x.status.value,
|
| estimated_duration=average_duration,
|
| node_id=node_id,
|
| auth_kwargs=auth,
|
| )
|
| task_result = await video_generate_operation.execute()
|
|
|
| file_id = task_result.file_id
|
| if file_id is None:
|
| raise Exception("Request was not successful. Missing file ID.")
|
| file_retrieve_operation = SynchronousOperation(
|
| endpoint=ApiEndpoint(
|
| path="/proxy/minimax/files/retrieve",
|
| method=HttpMethod.GET,
|
| request_model=EmptyRequest,
|
| response_model=MinimaxFileRetrieveResponse,
|
| query_params={"file_id": int(file_id)},
|
| ),
|
| request=EmptyRequest(),
|
| auth_kwargs=auth,
|
| )
|
| file_result = await file_retrieve_operation.execute()
|
|
|
| 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()}"
|
| )
|
| logging.info("Generated video URL: %s", file_url)
|
| if node_id:
|
| if hasattr(file_result.file, "backup_download_url"):
|
| message = f"Result URL: {file_url}\nBackup URL: {file_result.file.backup_download_url}"
|
| else:
|
| message = f"Result URL: {file_url}"
|
| PromptServer.instance.send_progress_text(message, node_id)
|
|
|
|
|
| video_io = await download_url_to_bytesio(file_url)
|
| if video_io is None:
|
| error_msg = f"Failed to download video from {file_url}"
|
| logging.error(error_msg)
|
| raise Exception(error_msg)
|
| return comfy_io.NodeOutput(VideoFromFile(video_io))
|
|
|
|
|
| class MinimaxTextToVideoNode(comfy_io.ComfyNode):
|
| """
|
| Generates videos synchronously based on a prompt, and optional parameters using MiniMax's API.
|
| """
|
|
|
| @classmethod
|
| def define_schema(cls) -> comfy_io.Schema:
|
| return comfy_io.Schema(
|
| node_id="MinimaxTextToVideoNode",
|
| display_name="MiniMax Text to Video",
|
| category="api node/video/MiniMax",
|
| description=cleandoc(cls.__doc__ or ""),
|
| inputs=[
|
| comfy_io.String.Input(
|
| "prompt_text",
|
| multiline=True,
|
| default="",
|
| tooltip="Text prompt to guide the video generation",
|
| ),
|
| comfy_io.Combo.Input(
|
| "model",
|
| options=["T2V-01", "T2V-01-Director"],
|
| default="T2V-01",
|
| tooltip="Model to use for video generation",
|
| ),
|
| comfy_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=[comfy_io.Video.Output()],
|
| hidden=[
|
| comfy_io.Hidden.auth_token_comfy_org,
|
| comfy_io.Hidden.api_key_comfy_org,
|
| comfy_io.Hidden.unique_id,
|
| ],
|
| is_api_node=True,
|
| )
|
|
|
| @classmethod
|
| async def execute(
|
| cls,
|
| prompt_text: str,
|
| model: str = "T2V-01",
|
| seed: int = 0,
|
| ) -> comfy_io.NodeOutput:
|
| return await _generate_mm_video(
|
| auth={
|
| "auth_token": cls.hidden.auth_token_comfy_org,
|
| "comfy_api_key": cls.hidden.api_key_comfy_org,
|
| },
|
| node_id=cls.hidden.unique_id,
|
| prompt_text=prompt_text,
|
| seed=seed,
|
| model=model,
|
| image=None,
|
| subject=None,
|
| average_duration=T2V_AVERAGE_DURATION,
|
| )
|
|
|
|
|
| class MinimaxImageToVideoNode(comfy_io.ComfyNode):
|
| """
|
| Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.
|
| """
|
|
|
| @classmethod
|
| def define_schema(cls) -> comfy_io.Schema:
|
| return comfy_io.Schema(
|
| node_id="MinimaxImageToVideoNode",
|
| display_name="MiniMax Image to Video",
|
| category="api node/video/MiniMax",
|
| description=cleandoc(cls.__doc__ or ""),
|
| inputs=[
|
| comfy_io.Image.Input(
|
| "image",
|
| tooltip="Image to use as first frame of video generation",
|
| ),
|
| comfy_io.String.Input(
|
| "prompt_text",
|
| multiline=True,
|
| default="",
|
| tooltip="Text prompt to guide the video generation",
|
| ),
|
| comfy_io.Combo.Input(
|
| "model",
|
| options=["I2V-01-Director", "I2V-01", "I2V-01-live"],
|
| default="I2V-01",
|
| tooltip="Model to use for video generation",
|
| ),
|
| comfy_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=[comfy_io.Video.Output()],
|
| hidden=[
|
| comfy_io.Hidden.auth_token_comfy_org,
|
| comfy_io.Hidden.api_key_comfy_org,
|
| comfy_io.Hidden.unique_id,
|
| ],
|
| is_api_node=True,
|
| )
|
|
|
| @classmethod
|
| async def execute(
|
| cls,
|
| image: torch.Tensor,
|
| prompt_text: str,
|
| model: str = "I2V-01",
|
| seed: int = 0,
|
| ) -> comfy_io.NodeOutput:
|
| return await _generate_mm_video(
|
| auth={
|
| "auth_token": cls.hidden.auth_token_comfy_org,
|
| "comfy_api_key": cls.hidden.api_key_comfy_org,
|
| },
|
| node_id=cls.hidden.unique_id,
|
| prompt_text=prompt_text,
|
| seed=seed,
|
| model=model,
|
| image=image,
|
| subject=None,
|
| average_duration=I2V_AVERAGE_DURATION,
|
| )
|
|
|
|
|
| class MinimaxSubjectToVideoNode(comfy_io.ComfyNode):
|
| """
|
| Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.
|
| """
|
|
|
| @classmethod
|
| def define_schema(cls) -> comfy_io.Schema:
|
| return comfy_io.Schema(
|
| node_id="MinimaxSubjectToVideoNode",
|
| display_name="MiniMax Subject to Video",
|
| category="api node/video/MiniMax",
|
| description=cleandoc(cls.__doc__ or ""),
|
| inputs=[
|
| comfy_io.Image.Input(
|
| "subject",
|
| tooltip="Image of subject to reference for video generation",
|
| ),
|
| comfy_io.String.Input(
|
| "prompt_text",
|
| multiline=True,
|
| default="",
|
| tooltip="Text prompt to guide the video generation",
|
| ),
|
| comfy_io.Combo.Input(
|
| "model",
|
| options=["S2V-01"],
|
| default="S2V-01",
|
| tooltip="Model to use for video generation",
|
| ),
|
| comfy_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=[comfy_io.Video.Output()],
|
| hidden=[
|
| comfy_io.Hidden.auth_token_comfy_org,
|
| comfy_io.Hidden.api_key_comfy_org,
|
| comfy_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,
|
| ) -> comfy_io.NodeOutput:
|
| return await _generate_mm_video(
|
| auth={
|
| "auth_token": cls.hidden.auth_token_comfy_org,
|
| "comfy_api_key": cls.hidden.api_key_comfy_org,
|
| },
|
| node_id=cls.hidden.unique_id,
|
| prompt_text=prompt_text,
|
| seed=seed,
|
| model=model,
|
| image=None,
|
| subject=subject,
|
| average_duration=T2V_AVERAGE_DURATION,
|
| )
|
|
|
|
|
| class MinimaxHailuoVideoNode(comfy_io.ComfyNode):
|
| """Generates videos from prompt, with optional start frame using the new MiniMax Hailuo-02 model."""
|
|
|
| @classmethod
|
| def define_schema(cls) -> comfy_io.Schema:
|
| return comfy_io.Schema(
|
| node_id="MinimaxHailuoVideoNode",
|
| display_name="MiniMax Hailuo Video",
|
| category="api node/video/MiniMax",
|
| description=cleandoc(cls.__doc__ or ""),
|
| inputs=[
|
| comfy_io.String.Input(
|
| "prompt_text",
|
| multiline=True,
|
| default="",
|
| tooltip="Text prompt to guide the video generation.",
|
| ),
|
| comfy_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,
|
| ),
|
| comfy_io.Image.Input(
|
| "first_frame_image",
|
| tooltip="Optional image to use as the first frame to generate a video.",
|
| optional=True,
|
| ),
|
| comfy_io.Boolean.Input(
|
| "prompt_optimizer",
|
| default=True,
|
| tooltip="Optimize prompt to improve generation quality when needed.",
|
| optional=True,
|
| ),
|
| comfy_io.Combo.Input(
|
| "duration",
|
| options=[6, 10],
|
| default=6,
|
| tooltip="The length of the output video in seconds.",
|
| optional=True,
|
| ),
|
| comfy_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=[comfy_io.Video.Output()],
|
| hidden=[
|
| comfy_io.Hidden.auth_token_comfy_org,
|
| comfy_io.Hidden.api_key_comfy_org,
|
| comfy_io.Hidden.unique_id,
|
| ],
|
| is_api_node=True,
|
| )
|
|
|
| @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",
|
| ) -> comfy_io.NodeOutput:
|
| auth = {
|
| "auth_token": cls.hidden.auth_token_comfy_org,
|
| "comfy_api_key": cls.hidden.api_key_comfy_org,
|
| }
|
| 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(first_frame_image, max_images=1, auth_kwargs=auth))[0]
|
|
|
| video_generate_operation = SynchronousOperation(
|
| endpoint=ApiEndpoint(
|
| path="/proxy/minimax/video_generation",
|
| method=HttpMethod.POST,
|
| request_model=MinimaxVideoGenerationRequest,
|
| response_model=MinimaxVideoGenerationResponse,
|
| ),
|
| request=MinimaxVideoGenerationRequest(
|
| model=MiniMaxModel(model),
|
| prompt=prompt_text,
|
| callback_url=None,
|
| first_frame_image=image_url,
|
| prompt_optimizer=prompt_optimizer,
|
| duration=duration,
|
| resolution=resolution,
|
| ),
|
| auth_kwargs=auth,
|
| )
|
| response = await video_generate_operation.execute()
|
|
|
| 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
|
| video_generate_operation = PollingOperation(
|
| poll_endpoint=ApiEndpoint(
|
| path="/proxy/minimax/query/video_generation",
|
| method=HttpMethod.GET,
|
| request_model=EmptyRequest,
|
| response_model=MinimaxTaskResultResponse,
|
| query_params={"task_id": task_id},
|
| ),
|
| completed_statuses=["Success"],
|
| failed_statuses=["Fail"],
|
| status_extractor=lambda x: x.status.value,
|
| estimated_duration=average_duration,
|
| node_id=cls.hidden.unique_id,
|
| auth_kwargs=auth,
|
| )
|
| task_result = await video_generate_operation.execute()
|
|
|
| file_id = task_result.file_id
|
| if file_id is None:
|
| raise Exception("Request was not successful. Missing file ID.")
|
| file_retrieve_operation = SynchronousOperation(
|
| endpoint=ApiEndpoint(
|
| path="/proxy/minimax/files/retrieve",
|
| method=HttpMethod.GET,
|
| request_model=EmptyRequest,
|
| response_model=MinimaxFileRetrieveResponse,
|
| query_params={"file_id": int(file_id)},
|
| ),
|
| request=EmptyRequest(),
|
| auth_kwargs=auth,
|
| )
|
| file_result = await file_retrieve_operation.execute()
|
|
|
| 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()}"
|
| )
|
| logging.info(f"Generated video URL: {file_url}")
|
| if cls.hidden.unique_id:
|
| if hasattr(file_result.file, "backup_download_url"):
|
| message = f"Result URL: {file_url}\nBackup URL: {file_result.file.backup_download_url}"
|
| else:
|
| message = f"Result URL: {file_url}"
|
| PromptServer.instance.send_progress_text(message, cls.hidden.unique_id)
|
|
|
| video_io = await download_url_to_bytesio(file_url)
|
| if video_io is None:
|
| error_msg = f"Failed to download video from {file_url}"
|
| logging.error(error_msg)
|
| raise Exception(error_msg)
|
| return comfy_io.NodeOutput(VideoFromFile(video_io))
|
|
|
|
|
| class MinimaxExtension(ComfyExtension):
|
| @override
|
| async def get_node_list(self) -> list[type[comfy_io.ComfyNode]]:
|
| return [
|
| MinimaxTextToVideoNode,
|
| MinimaxImageToVideoNode,
|
|
|
| MinimaxHailuoVideoNode,
|
| ]
|
|
|
|
|
| async def comfy_entrypoint() -> MinimaxExtension:
|
| return MinimaxExtension()
|
|
|