Skip to content

Commit f16a70b

Browse files
authored
api_nodes: add MinimaxHailuoVideoNode node (#9262)
1 parent 36b5127 commit f16a70b

File tree

2 files changed

+191
-7
lines changed

2 files changed

+191
-7
lines changed

comfy_api_nodes/apis/__init__.py

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

comfy_api_nodes/nodes_minimax.py

Lines changed: 180 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from inspect import cleandoc
12
from typing import Union
23
import logging
34
import torch
@@ -10,7 +11,7 @@
1011
MinimaxFileRetrieveResponse,
1112
MinimaxTaskResultResponse,
1213
SubjectReferenceItem,
13-
Model
14+
MiniMaxModel
1415
)
1516
from comfy_api_nodes.apis.client import (
1617
ApiEndpoint,
@@ -84,7 +85,6 @@ def INPUT_TYPES(s):
8485
FUNCTION = "generate_video"
8586
CATEGORY = "api node/video/MiniMax"
8687
API_NODE = True
87-
OUTPUT_NODE = True
8888

8989
async def generate_video(
9090
self,
@@ -121,7 +121,7 @@ async def generate_video(
121121
response_model=MinimaxVideoGenerationResponse,
122122
),
123123
request=MinimaxVideoGenerationRequest(
124-
model=Model(model),
124+
model=MiniMaxModel(model),
125125
prompt=prompt_text,
126126
callback_url=None,
127127
first_frame_image=image_url,
@@ -251,7 +251,6 @@ def INPUT_TYPES(s):
251251
FUNCTION = "generate_video"
252252
CATEGORY = "api node/video/MiniMax"
253253
API_NODE = True
254-
OUTPUT_NODE = True
255254

256255

257256
class MinimaxSubjectToVideoNode(MinimaxTextToVideoNode):
@@ -313,7 +312,181 @@ def INPUT_TYPES(s):
313312
FUNCTION = "generate_video"
314313
CATEGORY = "api node/video/MiniMax"
315314
API_NODE = True
316-
OUTPUT_NODE = True
315+
316+
317+
class MinimaxHailuoVideoNode:
318+
"""Generates videos from prompt, with optional start frame using the new MiniMax Hailuo-02 model."""
319+
320+
@classmethod
321+
def INPUT_TYPES(s):
322+
return {
323+
"required": {
324+
"prompt_text": (
325+
"STRING",
326+
{
327+
"multiline": True,
328+
"default": "",
329+
"tooltip": "Text prompt to guide the video generation.",
330+
},
331+
),
332+
},
333+
"optional": {
334+
"seed": (
335+
IO.INT,
336+
{
337+
"default": 0,
338+
"min": 0,
339+
"max": 0xFFFFFFFFFFFFFFFF,
340+
"control_after_generate": True,
341+
"tooltip": "The random seed used for creating the noise.",
342+
},
343+
),
344+
"first_frame_image": (
345+
IO.IMAGE,
346+
{
347+
"tooltip": "Optional image to use as the first frame to generate a video."
348+
},
349+
),
350+
"prompt_optimizer": (
351+
IO.BOOLEAN,
352+
{
353+
"tooltip": "Optimize prompt to improve generation quality when needed.",
354+
"default": True,
355+
},
356+
),
357+
"duration": (
358+
IO.COMBO,
359+
{
360+
"tooltip": "The length of the output video in seconds.",
361+
"default": 6,
362+
"options": [6, 10],
363+
},
364+
),
365+
"resolution": (
366+
IO.COMBO,
367+
{
368+
"tooltip": "The dimensions of the video display. "
369+
"1080p corresponds to 1920 x 1080 pixels, 768p corresponds to 1366 x 768 pixels.",
370+
"default": "768P",
371+
"options": ["768P", "1080P"],
372+
},
373+
),
374+
},
375+
"hidden": {
376+
"auth_token": "AUTH_TOKEN_COMFY_ORG",
377+
"comfy_api_key": "API_KEY_COMFY_ORG",
378+
"unique_id": "UNIQUE_ID",
379+
},
380+
}
381+
382+
RETURN_TYPES = ("VIDEO",)
383+
DESCRIPTION = cleandoc(__doc__ or "")
384+
FUNCTION = "generate_video"
385+
CATEGORY = "api node/video/MiniMax"
386+
API_NODE = True
387+
388+
async def generate_video(
389+
self,
390+
prompt_text,
391+
seed=0,
392+
first_frame_image: torch.Tensor=None, # used for ImageToVideo
393+
prompt_optimizer=True,
394+
duration=6,
395+
resolution="768P",
396+
model="MiniMax-Hailuo-02",
397+
unique_id: Union[str, None]=None,
398+
**kwargs,
399+
):
400+
if first_frame_image is None:
401+
validate_string(prompt_text, field_name="prompt_text")
402+
403+
if model == "MiniMax-Hailuo-02" and resolution.upper() == "1080P" and duration != 6:
404+
raise Exception(
405+
"When model is MiniMax-Hailuo-02 and resolution is 1080P, duration is limited to 6 seconds."
406+
)
407+
408+
# upload image, if passed in
409+
image_url = None
410+
if first_frame_image is not None:
411+
image_url = (await upload_images_to_comfyapi(first_frame_image, max_images=1, auth_kwargs=kwargs))[0]
412+
413+
video_generate_operation = SynchronousOperation(
414+
endpoint=ApiEndpoint(
415+
path="/proxy/minimax/video_generation",
416+
method=HttpMethod.POST,
417+
request_model=MinimaxVideoGenerationRequest,
418+
response_model=MinimaxVideoGenerationResponse,
419+
),
420+
request=MinimaxVideoGenerationRequest(
421+
model=MiniMaxModel(model),
422+
prompt=prompt_text,
423+
callback_url=None,
424+
first_frame_image=image_url,
425+
prompt_optimizer=prompt_optimizer,
426+
duration=duration,
427+
resolution=resolution,
428+
),
429+
auth_kwargs=kwargs,
430+
)
431+
response = await video_generate_operation.execute()
432+
433+
task_id = response.task_id
434+
if not task_id:
435+
raise Exception(f"MiniMax generation failed: {response.base_resp}")
436+
437+
average_duration = 120 if resolution == "768P" else 240
438+
video_generate_operation = PollingOperation(
439+
poll_endpoint=ApiEndpoint(
440+
path="/proxy/minimax/query/video_generation",
441+
method=HttpMethod.GET,
442+
request_model=EmptyRequest,
443+
response_model=MinimaxTaskResultResponse,
444+
query_params={"task_id": task_id},
445+
),
446+
completed_statuses=["Success"],
447+
failed_statuses=["Fail"],
448+
status_extractor=lambda x: x.status.value,
449+
estimated_duration=average_duration,
450+
node_id=unique_id,
451+
auth_kwargs=kwargs,
452+
)
453+
task_result = await video_generate_operation.execute()
454+
455+
file_id = task_result.file_id
456+
if file_id is None:
457+
raise Exception("Request was not successful. Missing file ID.")
458+
file_retrieve_operation = SynchronousOperation(
459+
endpoint=ApiEndpoint(
460+
path="/proxy/minimax/files/retrieve",
461+
method=HttpMethod.GET,
462+
request_model=EmptyRequest,
463+
response_model=MinimaxFileRetrieveResponse,
464+
query_params={"file_id": int(file_id)},
465+
),
466+
request=EmptyRequest(),
467+
auth_kwargs=kwargs,
468+
)
469+
file_result = await file_retrieve_operation.execute()
470+
471+
file_url = file_result.file.download_url
472+
if file_url is None:
473+
raise Exception(
474+
f"No video was found in the response. Full response: {file_result.model_dump()}"
475+
)
476+
logging.info(f"Generated video URL: {file_url}")
477+
if unique_id:
478+
if hasattr(file_result.file, "backup_download_url"):
479+
message = f"Result URL: {file_url}\nBackup URL: {file_result.file.backup_download_url}"
480+
else:
481+
message = f"Result URL: {file_url}"
482+
PromptServer.instance.send_progress_text(message, unique_id)
483+
484+
video_io = await download_url_to_bytesio(file_url)
485+
if video_io is None:
486+
error_msg = f"Failed to download video from {file_url}"
487+
logging.error(error_msg)
488+
raise Exception(error_msg)
489+
return (VideoFromFile(video_io),)
317490

318491

319492
# A dictionary that contains all nodes you want to export with their names
@@ -322,11 +495,13 @@ def INPUT_TYPES(s):
322495
"MinimaxTextToVideoNode": MinimaxTextToVideoNode,
323496
"MinimaxImageToVideoNode": MinimaxImageToVideoNode,
324497
# "MinimaxSubjectToVideoNode": MinimaxSubjectToVideoNode,
498+
"MinimaxHailuoVideoNode": MinimaxHailuoVideoNode,
325499
}
326500

327501
# A dictionary that contains the friendly/humanly readable titles for the nodes
328502
NODE_DISPLAY_NAME_MAPPINGS = {
329503
"MinimaxTextToVideoNode": "MiniMax Text to Video",
330504
"MinimaxImageToVideoNode": "MiniMax Image to Video",
331505
"MinimaxSubjectToVideoNode": "MiniMax Subject to Video",
506+
"MinimaxHailuoVideoNode": "MiniMax Hailuo Video",
332507
}

0 commit comments

Comments
 (0)