mirror of
https://git.datalinker.icu/comfyanonymous/ComfyUI
synced 2025-12-15 17:14:48 +08:00
use new API client in Luma and Minimax nodes (#10528)
This commit is contained in:
parent
e525673f72
commit
6c14f3afac
@ -3,14 +3,6 @@ import aiohttp
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from comfy.utils import common_upscale
|
from comfy.utils import common_upscale
|
||||||
from comfy_api_nodes.apis.client import (
|
|
||||||
ApiClient,
|
|
||||||
ApiEndpoint,
|
|
||||||
HttpMethod,
|
|
||||||
SynchronousOperation,
|
|
||||||
UploadRequest,
|
|
||||||
UploadResponse,
|
|
||||||
)
|
|
||||||
from server import PromptServer
|
from server import PromptServer
|
||||||
from comfy.cli_args import args
|
from comfy.cli_args import args
|
||||||
|
|
||||||
@ -19,7 +11,6 @@ from PIL import Image
|
|||||||
import torch
|
import torch
|
||||||
import math
|
import math
|
||||||
import base64
|
import base64
|
||||||
from .util import tensor_to_bytesio, bytesio_to_image_tensor
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
@ -148,11 +139,6 @@ async def download_url_to_bytesio(
|
|||||||
return BytesIO(await resp.read())
|
return BytesIO(await resp.read())
|
||||||
|
|
||||||
|
|
||||||
def process_image_response(response_content: bytes | str) -> torch.Tensor:
|
|
||||||
"""Uses content from a Response object and converts it to a torch.Tensor"""
|
|
||||||
return bytesio_to_image_tensor(BytesIO(response_content))
|
|
||||||
|
|
||||||
|
|
||||||
def text_filepath_to_base64_string(filepath: str) -> str:
|
def text_filepath_to_base64_string(filepath: str) -> str:
|
||||||
"""Converts a text file to a base64 string."""
|
"""Converts a text file to a base64 string."""
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
@ -169,73 +155,6 @@ def text_filepath_to_data_uri(filepath: str) -> str:
|
|||||||
return f"data:{mime_type};base64,{base64_string}"
|
return f"data:{mime_type};base64,{base64_string}"
|
||||||
|
|
||||||
|
|
||||||
async def upload_file_to_comfyapi(
|
|
||||||
file_bytes_io: BytesIO,
|
|
||||||
filename: str,
|
|
||||||
upload_mime_type: Optional[str],
|
|
||||||
auth_kwargs: Optional[dict[str, str]] = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Uploads a single file to ComfyUI API and returns its download URL.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_bytes_io: BytesIO object containing the file data.
|
|
||||||
filename: The filename of the file.
|
|
||||||
upload_mime_type: MIME type of the file.
|
|
||||||
auth_kwargs: Optional authentication token(s).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The download URL for the uploaded file.
|
|
||||||
"""
|
|
||||||
if upload_mime_type is None:
|
|
||||||
request_object = UploadRequest(file_name=filename)
|
|
||||||
else:
|
|
||||||
request_object = UploadRequest(file_name=filename, content_type=upload_mime_type)
|
|
||||||
operation = SynchronousOperation(
|
|
||||||
endpoint=ApiEndpoint(
|
|
||||||
path="/customers/storage",
|
|
||||||
method=HttpMethod.POST,
|
|
||||||
request_model=UploadRequest,
|
|
||||||
response_model=UploadResponse,
|
|
||||||
),
|
|
||||||
request=request_object,
|
|
||||||
auth_kwargs=auth_kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
response: UploadResponse = await operation.execute()
|
|
||||||
await ApiClient.upload_file(response.upload_url, file_bytes_io, content_type=upload_mime_type)
|
|
||||||
return response.download_url
|
|
||||||
|
|
||||||
|
|
||||||
async def upload_images_to_comfyapi(
|
|
||||||
image: torch.Tensor,
|
|
||||||
max_images=8,
|
|
||||||
auth_kwargs: Optional[dict[str, str]] = None,
|
|
||||||
mime_type: Optional[str] = None,
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Uploads images to ComfyUI API and returns download URLs.
|
|
||||||
To upload multiple images, stack them in the batch dimension first.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image: Input torch.Tensor image.
|
|
||||||
max_images: Maximum number of images to upload.
|
|
||||||
auth_kwargs: Optional authentication token(s).
|
|
||||||
mime_type: Optional MIME type for the image.
|
|
||||||
"""
|
|
||||||
# if batch, try to upload each file if max_images is greater than 0
|
|
||||||
download_urls: list[str] = []
|
|
||||||
is_batch = len(image.shape) > 3
|
|
||||||
batch_len = image.shape[0] if is_batch else 1
|
|
||||||
|
|
||||||
for idx in range(min(batch_len, max_images)):
|
|
||||||
tensor = image[idx] if is_batch else image
|
|
||||||
img_io = tensor_to_bytesio(tensor, mime_type=mime_type)
|
|
||||||
url = await upload_file_to_comfyapi(img_io, img_io.name, mime_type, auth_kwargs)
|
|
||||||
download_urls.append(url)
|
|
||||||
return download_urls
|
|
||||||
|
|
||||||
|
|
||||||
def resize_mask_to_image(
|
def resize_mask_to_image(
|
||||||
mask: torch.Tensor,
|
mask: torch.Tensor,
|
||||||
image: torch.Tensor,
|
image: torch.Tensor,
|
||||||
|
|||||||
120
comfy_api_nodes/apis/minimax_api.py
Normal file
120
comfy_api_nodes/apis/minimax_api.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MinimaxBaseResponse(BaseModel):
|
||||||
|
status_code: int = Field(
|
||||||
|
...,
|
||||||
|
description='Status code. 0 indicates success, other values indicate errors.',
|
||||||
|
)
|
||||||
|
status_msg: str = Field(
|
||||||
|
..., description='Specific error details or success message.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class File(BaseModel):
|
||||||
|
bytes: Optional[int] = Field(None, description='File size in bytes')
|
||||||
|
created_at: Optional[int] = Field(
|
||||||
|
None, description='Unix timestamp when the file was created, in seconds'
|
||||||
|
)
|
||||||
|
download_url: Optional[str] = Field(
|
||||||
|
None, description='The URL to download the video'
|
||||||
|
)
|
||||||
|
backup_download_url: Optional[str] = Field(
|
||||||
|
None, description='The backup URL to download the video'
|
||||||
|
)
|
||||||
|
|
||||||
|
file_id: Optional[int] = Field(None, description='Unique identifier for the file')
|
||||||
|
filename: Optional[str] = Field(None, description='The name of the file')
|
||||||
|
purpose: Optional[str] = Field(None, description='The purpose of using the file')
|
||||||
|
|
||||||
|
|
||||||
|
class MinimaxFileRetrieveResponse(BaseModel):
|
||||||
|
base_resp: MinimaxBaseResponse
|
||||||
|
file: File
|
||||||
|
|
||||||
|
|
||||||
|
class MiniMaxModel(str, Enum):
|
||||||
|
T2V_01_Director = 'T2V-01-Director'
|
||||||
|
I2V_01_Director = 'I2V-01-Director'
|
||||||
|
S2V_01 = 'S2V-01'
|
||||||
|
I2V_01 = 'I2V-01'
|
||||||
|
I2V_01_live = 'I2V-01-live'
|
||||||
|
T2V_01 = 'T2V-01'
|
||||||
|
Hailuo_02 = 'MiniMax-Hailuo-02'
|
||||||
|
|
||||||
|
|
||||||
|
class Status6(str, Enum):
|
||||||
|
Queueing = 'Queueing'
|
||||||
|
Preparing = 'Preparing'
|
||||||
|
Processing = 'Processing'
|
||||||
|
Success = 'Success'
|
||||||
|
Fail = 'Fail'
|
||||||
|
|
||||||
|
|
||||||
|
class MinimaxTaskResultResponse(BaseModel):
|
||||||
|
base_resp: MinimaxBaseResponse
|
||||||
|
file_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description='After the task status changes to Success, this field returns the file ID corresponding to the generated video.',
|
||||||
|
)
|
||||||
|
status: Status6 = Field(
|
||||||
|
...,
|
||||||
|
description="Task status: 'Queueing' (in queue), 'Preparing' (task is preparing), 'Processing' (generating), 'Success' (task completed successfully), or 'Fail' (task failed).",
|
||||||
|
)
|
||||||
|
task_id: str = Field(..., description='The task ID being queried.')
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectReferenceItem(BaseModel):
|
||||||
|
image: Optional[str] = Field(
|
||||||
|
None, description='URL or base64 encoding of the subject reference image.'
|
||||||
|
)
|
||||||
|
mask: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description='URL or base64 encoding of the mask for the subject reference image.',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MinimaxVideoGenerationRequest(BaseModel):
|
||||||
|
callback_url: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description='Optional. URL to receive real-time status updates about the video generation task.',
|
||||||
|
)
|
||||||
|
first_frame_image: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description='URL or base64 encoding of the first frame image. Required when model is I2V-01, I2V-01-Director, or I2V-01-live.',
|
||||||
|
)
|
||||||
|
model: MiniMaxModel = Field(
|
||||||
|
...,
|
||||||
|
description='Required. ID of model. Options: T2V-01-Director, I2V-01-Director, S2V-01, I2V-01, I2V-01-live, T2V-01',
|
||||||
|
)
|
||||||
|
prompt: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description='Description of the video. Should be less than 2000 characters. Supports camera movement instructions in [brackets].',
|
||||||
|
max_length=2000,
|
||||||
|
)
|
||||||
|
prompt_optimizer: Optional[bool] = Field(
|
||||||
|
True,
|
||||||
|
description='If true (default), the model will automatically optimize the prompt. Set to false for more precise control.',
|
||||||
|
)
|
||||||
|
subject_reference: Optional[list[SubjectReferenceItem]] = Field(
|
||||||
|
None,
|
||||||
|
description='Only available when model is S2V-01. The model will generate a video based on the subject uploaded through this parameter.',
|
||||||
|
)
|
||||||
|
duration: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description="The length of the output video in seconds."
|
||||||
|
)
|
||||||
|
resolution: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="The dimensions of the video display. 1080p corresponds to 1920 x 1080 pixels, 768p corresponds to 1366 x 768 pixels."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MinimaxVideoGenerationResponse(BaseModel):
|
||||||
|
base_resp: MinimaxBaseResponse
|
||||||
|
task_id: str = Field(
|
||||||
|
..., description='The task ID for the asynchronous video generation task.'
|
||||||
|
)
|
||||||
@ -20,9 +20,9 @@ from comfy_api_nodes.apis.client import (
|
|||||||
|
|
||||||
from comfy_api_nodes.apinode_utils import (
|
from comfy_api_nodes.apinode_utils import (
|
||||||
download_url_to_bytesio,
|
download_url_to_bytesio,
|
||||||
bytesio_to_image_tensor,
|
|
||||||
resize_mask_to_image,
|
resize_mask_to_image,
|
||||||
)
|
)
|
||||||
|
from comfy_api_nodes.util import bytesio_to_image_tensor
|
||||||
from server import PromptServer
|
from server import PromptServer
|
||||||
|
|
||||||
V1_V1_RES_MAP = {
|
V1_V1_RES_MAP = {
|
||||||
|
|||||||
@ -1,69 +1,51 @@
|
|||||||
from __future__ import annotations
|
|
||||||
from inspect import cleandoc
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import torch
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
from comfy_api.latest import ComfyExtension, IO
|
|
||||||
from comfy_api.input_impl.video_types import VideoFromFile
|
from comfy_api.latest import IO, ComfyExtension
|
||||||
from comfy_api_nodes.apis.luma_api import (
|
from comfy_api_nodes.apis.luma_api import (
|
||||||
LumaImageModel,
|
|
||||||
LumaVideoModel,
|
|
||||||
LumaVideoOutputResolution,
|
|
||||||
LumaVideoModelOutputDuration,
|
|
||||||
LumaAspectRatio,
|
LumaAspectRatio,
|
||||||
LumaState,
|
|
||||||
LumaImageGenerationRequest,
|
|
||||||
LumaGenerationRequest,
|
|
||||||
LumaGeneration,
|
|
||||||
LumaCharacterRef,
|
LumaCharacterRef,
|
||||||
LumaModifyImageRef,
|
LumaConceptChain,
|
||||||
|
LumaGeneration,
|
||||||
|
LumaGenerationRequest,
|
||||||
|
LumaImageGenerationRequest,
|
||||||
LumaImageIdentity,
|
LumaImageIdentity,
|
||||||
|
LumaImageModel,
|
||||||
|
LumaImageReference,
|
||||||
|
LumaIO,
|
||||||
|
LumaKeyframes,
|
||||||
|
LumaModifyImageRef,
|
||||||
LumaReference,
|
LumaReference,
|
||||||
LumaReferenceChain,
|
LumaReferenceChain,
|
||||||
LumaImageReference,
|
LumaVideoModel,
|
||||||
LumaKeyframes,
|
LumaVideoModelOutputDuration,
|
||||||
LumaConceptChain,
|
LumaVideoOutputResolution,
|
||||||
LumaIO,
|
|
||||||
get_luma_concepts,
|
get_luma_concepts,
|
||||||
)
|
)
|
||||||
from comfy_api_nodes.apis.client import (
|
from comfy_api_nodes.util import (
|
||||||
ApiEndpoint,
|
ApiEndpoint,
|
||||||
HttpMethod,
|
download_url_to_image_tensor,
|
||||||
SynchronousOperation,
|
download_url_to_video_output,
|
||||||
PollingOperation,
|
poll_op,
|
||||||
EmptyRequest,
|
sync_op,
|
||||||
)
|
|
||||||
from comfy_api_nodes.apinode_utils import (
|
|
||||||
upload_images_to_comfyapi,
|
upload_images_to_comfyapi,
|
||||||
process_image_response,
|
validate_string,
|
||||||
)
|
)
|
||||||
from server import PromptServer
|
|
||||||
from comfy_api_nodes.util import validate_string
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import torch
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
LUMA_T2V_AVERAGE_DURATION = 105
|
LUMA_T2V_AVERAGE_DURATION = 105
|
||||||
LUMA_I2V_AVERAGE_DURATION = 100
|
LUMA_I2V_AVERAGE_DURATION = 100
|
||||||
|
|
||||||
def image_result_url_extractor(response: LumaGeneration):
|
|
||||||
return response.assets.image if hasattr(response, "assets") and hasattr(response.assets, "image") else None
|
|
||||||
|
|
||||||
def video_result_url_extractor(response: LumaGeneration):
|
|
||||||
return response.assets.video if hasattr(response, "assets") and hasattr(response.assets, "video") else None
|
|
||||||
|
|
||||||
class LumaReferenceNode(IO.ComfyNode):
|
class LumaReferenceNode(IO.ComfyNode):
|
||||||
"""
|
|
||||||
Holds an image and weight for use with Luma Generate Image node.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls) -> IO.Schema:
|
def define_schema(cls) -> IO.Schema:
|
||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="LumaReferenceNode",
|
node_id="LumaReferenceNode",
|
||||||
display_name="Luma Reference",
|
display_name="Luma Reference",
|
||||||
category="api node/image/Luma",
|
category="api node/image/Luma",
|
||||||
description=cleandoc(cls.__doc__ or ""),
|
description="Holds an image and weight for use with Luma Generate Image node.",
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.Image.Input(
|
IO.Image.Input(
|
||||||
"image",
|
"image",
|
||||||
@ -83,17 +65,10 @@ class LumaReferenceNode(IO.ComfyNode):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
outputs=[IO.Custom(LumaIO.LUMA_REF).Output(display_name="luma_ref")],
|
outputs=[IO.Custom(LumaIO.LUMA_REF).Output(display_name="luma_ref")],
|
||||||
hidden=[
|
|
||||||
IO.Hidden.auth_token_comfy_org,
|
|
||||||
IO.Hidden.api_key_comfy_org,
|
|
||||||
IO.Hidden.unique_id,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(
|
def execute(cls, image: torch.Tensor, weight: float, luma_ref: LumaReferenceChain = None) -> IO.NodeOutput:
|
||||||
cls, image: torch.Tensor, weight: float, luma_ref: LumaReferenceChain = None
|
|
||||||
) -> IO.NodeOutput:
|
|
||||||
if luma_ref is not None:
|
if luma_ref is not None:
|
||||||
luma_ref = luma_ref.clone()
|
luma_ref = luma_ref.clone()
|
||||||
else:
|
else:
|
||||||
@ -103,17 +78,13 @@ class LumaReferenceNode(IO.ComfyNode):
|
|||||||
|
|
||||||
|
|
||||||
class LumaConceptsNode(IO.ComfyNode):
|
class LumaConceptsNode(IO.ComfyNode):
|
||||||
"""
|
|
||||||
Holds one or more Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls) -> IO.Schema:
|
def define_schema(cls) -> IO.Schema:
|
||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="LumaConceptsNode",
|
node_id="LumaConceptsNode",
|
||||||
display_name="Luma Concepts",
|
display_name="Luma Concepts",
|
||||||
category="api node/video/Luma",
|
category="api node/video/Luma",
|
||||||
description=cleandoc(cls.__doc__ or ""),
|
description="Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.",
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.Combo.Input(
|
IO.Combo.Input(
|
||||||
"concept1",
|
"concept1",
|
||||||
@ -138,11 +109,6 @@ class LumaConceptsNode(IO.ComfyNode):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
outputs=[IO.Custom(LumaIO.LUMA_CONCEPTS).Output(display_name="luma_concepts")],
|
outputs=[IO.Custom(LumaIO.LUMA_CONCEPTS).Output(display_name="luma_concepts")],
|
||||||
hidden=[
|
|
||||||
IO.Hidden.auth_token_comfy_org,
|
|
||||||
IO.Hidden.api_key_comfy_org,
|
|
||||||
IO.Hidden.unique_id,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -161,17 +127,13 @@ class LumaConceptsNode(IO.ComfyNode):
|
|||||||
|
|
||||||
|
|
||||||
class LumaImageGenerationNode(IO.ComfyNode):
|
class LumaImageGenerationNode(IO.ComfyNode):
|
||||||
"""
|
|
||||||
Generates images synchronously based on prompt and aspect ratio.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls) -> IO.Schema:
|
def define_schema(cls) -> IO.Schema:
|
||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="LumaImageNode",
|
node_id="LumaImageNode",
|
||||||
display_name="Luma Text to Image",
|
display_name="Luma Text to Image",
|
||||||
category="api node/image/Luma",
|
category="api node/image/Luma",
|
||||||
description=cleandoc(cls.__doc__ or ""),
|
description="Generates images synchronously based on prompt and aspect ratio.",
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.String.Input(
|
IO.String.Input(
|
||||||
"prompt",
|
"prompt",
|
||||||
@ -237,45 +199,30 @@ class LumaImageGenerationNode(IO.ComfyNode):
|
|||||||
aspect_ratio: str,
|
aspect_ratio: str,
|
||||||
seed,
|
seed,
|
||||||
style_image_weight: float,
|
style_image_weight: float,
|
||||||
image_luma_ref: LumaReferenceChain = None,
|
image_luma_ref: Optional[LumaReferenceChain] = None,
|
||||||
style_image: torch.Tensor = None,
|
style_image: Optional[torch.Tensor] = None,
|
||||||
character_image: torch.Tensor = None,
|
character_image: Optional[torch.Tensor] = None,
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
validate_string(prompt, strip_whitespace=True, min_length=3)
|
validate_string(prompt, strip_whitespace=True, min_length=3)
|
||||||
auth_kwargs = {
|
|
||||||
"auth_token": cls.hidden.auth_token_comfy_org,
|
|
||||||
"comfy_api_key": cls.hidden.api_key_comfy_org,
|
|
||||||
}
|
|
||||||
# handle image_luma_ref
|
# handle image_luma_ref
|
||||||
api_image_ref = None
|
api_image_ref = None
|
||||||
if image_luma_ref is not None:
|
if image_luma_ref is not None:
|
||||||
api_image_ref = await cls._convert_luma_refs(
|
api_image_ref = await cls._convert_luma_refs(image_luma_ref, max_refs=4)
|
||||||
image_luma_ref, max_refs=4, auth_kwargs=auth_kwargs,
|
|
||||||
)
|
|
||||||
# handle style_luma_ref
|
# handle style_luma_ref
|
||||||
api_style_ref = None
|
api_style_ref = None
|
||||||
if style_image is not None:
|
if style_image is not None:
|
||||||
api_style_ref = await cls._convert_style_image(
|
api_style_ref = await cls._convert_style_image(style_image, weight=style_image_weight)
|
||||||
style_image, weight=style_image_weight, auth_kwargs=auth_kwargs,
|
|
||||||
)
|
|
||||||
# handle character_ref images
|
# handle character_ref images
|
||||||
character_ref = None
|
character_ref = None
|
||||||
if character_image is not None:
|
if character_image is not None:
|
||||||
download_urls = await upload_images_to_comfyapi(
|
download_urls = await upload_images_to_comfyapi(cls, character_image, max_images=4)
|
||||||
character_image, max_images=4, auth_kwargs=auth_kwargs,
|
character_ref = LumaCharacterRef(identity0=LumaImageIdentity(images=download_urls))
|
||||||
)
|
|
||||||
character_ref = LumaCharacterRef(
|
|
||||||
identity0=LumaImageIdentity(images=download_urls)
|
|
||||||
)
|
|
||||||
|
|
||||||
operation = SynchronousOperation(
|
response_api = await sync_op(
|
||||||
endpoint=ApiEndpoint(
|
cls,
|
||||||
path="/proxy/luma/generations/image",
|
ApiEndpoint(path="/proxy/luma/generations/image", method="POST"),
|
||||||
method=HttpMethod.POST,
|
response_model=LumaGeneration,
|
||||||
request_model=LumaImageGenerationRequest,
|
data=LumaImageGenerationRequest(
|
||||||
response_model=LumaGeneration,
|
|
||||||
),
|
|
||||||
request=LumaImageGenerationRequest(
|
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=model,
|
model=model,
|
||||||
aspect_ratio=aspect_ratio,
|
aspect_ratio=aspect_ratio,
|
||||||
@ -283,41 +230,21 @@ class LumaImageGenerationNode(IO.ComfyNode):
|
|||||||
style_ref=api_style_ref,
|
style_ref=api_style_ref,
|
||||||
character_ref=character_ref,
|
character_ref=character_ref,
|
||||||
),
|
),
|
||||||
auth_kwargs=auth_kwargs,
|
|
||||||
)
|
)
|
||||||
response_api: LumaGeneration = await operation.execute()
|
response_poll = await poll_op(
|
||||||
|
cls,
|
||||||
operation = PollingOperation(
|
ApiEndpoint(path=f"/proxy/luma/generations/{response_api.id}"),
|
||||||
poll_endpoint=ApiEndpoint(
|
response_model=LumaGeneration,
|
||||||
path=f"/proxy/luma/generations/{response_api.id}",
|
|
||||||
method=HttpMethod.GET,
|
|
||||||
request_model=EmptyRequest,
|
|
||||||
response_model=LumaGeneration,
|
|
||||||
),
|
|
||||||
completed_statuses=[LumaState.completed],
|
|
||||||
failed_statuses=[LumaState.failed],
|
|
||||||
status_extractor=lambda x: x.state,
|
status_extractor=lambda x: x.state,
|
||||||
result_url_extractor=image_result_url_extractor,
|
|
||||||
node_id=cls.hidden.unique_id,
|
|
||||||
auth_kwargs=auth_kwargs,
|
|
||||||
)
|
)
|
||||||
response_poll = await operation.execute()
|
return IO.NodeOutput(await download_url_to_image_tensor(response_poll.assets.image))
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(response_poll.assets.image) as img_response:
|
|
||||||
img = process_image_response(await img_response.content.read())
|
|
||||||
return IO.NodeOutput(img)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _convert_luma_refs(
|
async def _convert_luma_refs(cls, luma_ref: LumaReferenceChain, max_refs: int):
|
||||||
cls, luma_ref: LumaReferenceChain, max_refs: int, auth_kwargs: Optional[dict[str,str]] = None
|
|
||||||
):
|
|
||||||
luma_urls = []
|
luma_urls = []
|
||||||
ref_count = 0
|
ref_count = 0
|
||||||
for ref in luma_ref.refs:
|
for ref in luma_ref.refs:
|
||||||
download_urls = await upload_images_to_comfyapi(
|
download_urls = await upload_images_to_comfyapi(cls, ref.image, max_images=1)
|
||||||
ref.image, max_images=1, auth_kwargs=auth_kwargs
|
|
||||||
)
|
|
||||||
luma_urls.append(download_urls[0])
|
luma_urls.append(download_urls[0])
|
||||||
ref_count += 1
|
ref_count += 1
|
||||||
if ref_count >= max_refs:
|
if ref_count >= max_refs:
|
||||||
@ -325,27 +252,19 @@ class LumaImageGenerationNode(IO.ComfyNode):
|
|||||||
return luma_ref.create_api_model(download_urls=luma_urls, max_refs=max_refs)
|
return luma_ref.create_api_model(download_urls=luma_urls, max_refs=max_refs)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _convert_style_image(
|
async def _convert_style_image(cls, style_image: torch.Tensor, weight: float):
|
||||||
cls, style_image: torch.Tensor, weight: float, auth_kwargs: Optional[dict[str,str]] = None
|
chain = LumaReferenceChain(first_ref=LumaReference(image=style_image, weight=weight))
|
||||||
):
|
return await cls._convert_luma_refs(chain, max_refs=1)
|
||||||
chain = LumaReferenceChain(
|
|
||||||
first_ref=LumaReference(image=style_image, weight=weight)
|
|
||||||
)
|
|
||||||
return await cls._convert_luma_refs(chain, max_refs=1, auth_kwargs=auth_kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class LumaImageModifyNode(IO.ComfyNode):
|
class LumaImageModifyNode(IO.ComfyNode):
|
||||||
"""
|
|
||||||
Modifies images synchronously based on prompt and aspect ratio.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls) -> IO.Schema:
|
def define_schema(cls) -> IO.Schema:
|
||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="LumaImageModifyNode",
|
node_id="LumaImageModifyNode",
|
||||||
display_name="Luma Image to Image",
|
display_name="Luma Image to Image",
|
||||||
category="api node/image/Luma",
|
category="api node/image/Luma",
|
||||||
description=cleandoc(cls.__doc__ or ""),
|
description="Modifies images synchronously based on prompt and aspect ratio.",
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.Image.Input(
|
IO.Image.Input(
|
||||||
"image",
|
"image",
|
||||||
@ -395,68 +314,37 @@ class LumaImageModifyNode(IO.ComfyNode):
|
|||||||
image_weight: float,
|
image_weight: float,
|
||||||
seed,
|
seed,
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
auth_kwargs = {
|
download_urls = await upload_images_to_comfyapi(cls, image, max_images=1)
|
||||||
"auth_token": cls.hidden.auth_token_comfy_org,
|
|
||||||
"comfy_api_key": cls.hidden.api_key_comfy_org,
|
|
||||||
}
|
|
||||||
# first, upload image
|
|
||||||
download_urls = await upload_images_to_comfyapi(
|
|
||||||
image, max_images=1, auth_kwargs=auth_kwargs,
|
|
||||||
)
|
|
||||||
image_url = download_urls[0]
|
image_url = download_urls[0]
|
||||||
# next, make Luma call with download url provided
|
response_api = await sync_op(
|
||||||
operation = SynchronousOperation(
|
cls,
|
||||||
endpoint=ApiEndpoint(
|
ApiEndpoint(path="/proxy/luma/generations/image", method="POST"),
|
||||||
path="/proxy/luma/generations/image",
|
response_model=LumaGeneration,
|
||||||
method=HttpMethod.POST,
|
data=LumaImageGenerationRequest(
|
||||||
request_model=LumaImageGenerationRequest,
|
|
||||||
response_model=LumaGeneration,
|
|
||||||
),
|
|
||||||
request=LumaImageGenerationRequest(
|
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=model,
|
model=model,
|
||||||
modify_image_ref=LumaModifyImageRef(
|
modify_image_ref=LumaModifyImageRef(
|
||||||
url=image_url, weight=round(max(min(1.0-image_weight, 0.98), 0.0), 2)
|
url=image_url, weight=round(max(min(1.0 - image_weight, 0.98), 0.0), 2)
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
auth_kwargs=auth_kwargs,
|
|
||||||
)
|
)
|
||||||
response_api: LumaGeneration = await operation.execute()
|
response_poll = await poll_op(
|
||||||
|
cls,
|
||||||
operation = PollingOperation(
|
ApiEndpoint(path=f"/proxy/luma/generations/{response_api.id}"),
|
||||||
poll_endpoint=ApiEndpoint(
|
response_model=LumaGeneration,
|
||||||
path=f"/proxy/luma/generations/{response_api.id}",
|
|
||||||
method=HttpMethod.GET,
|
|
||||||
request_model=EmptyRequest,
|
|
||||||
response_model=LumaGeneration,
|
|
||||||
),
|
|
||||||
completed_statuses=[LumaState.completed],
|
|
||||||
failed_statuses=[LumaState.failed],
|
|
||||||
status_extractor=lambda x: x.state,
|
status_extractor=lambda x: x.state,
|
||||||
result_url_extractor=image_result_url_extractor,
|
|
||||||
node_id=cls.hidden.unique_id,
|
|
||||||
auth_kwargs=auth_kwargs,
|
|
||||||
)
|
)
|
||||||
response_poll = await operation.execute()
|
return IO.NodeOutput(await download_url_to_image_tensor(response_poll.assets.image))
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(response_poll.assets.image) as img_response:
|
|
||||||
img = process_image_response(await img_response.content.read())
|
|
||||||
return IO.NodeOutput(img)
|
|
||||||
|
|
||||||
|
|
||||||
class LumaTextToVideoGenerationNode(IO.ComfyNode):
|
class LumaTextToVideoGenerationNode(IO.ComfyNode):
|
||||||
"""
|
|
||||||
Generates videos synchronously based on prompt and output_size.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls) -> IO.Schema:
|
def define_schema(cls) -> IO.Schema:
|
||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="LumaVideoNode",
|
node_id="LumaVideoNode",
|
||||||
display_name="Luma Text to Video",
|
display_name="Luma Text to Video",
|
||||||
category="api node/video/Luma",
|
category="api node/video/Luma",
|
||||||
description=cleandoc(cls.__doc__ or ""),
|
description="Generates videos synchronously based on prompt and output_size.",
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.String.Input(
|
IO.String.Input(
|
||||||
"prompt",
|
"prompt",
|
||||||
@ -498,7 +386,7 @@ class LumaTextToVideoGenerationNode(IO.ComfyNode):
|
|||||||
"luma_concepts",
|
"luma_concepts",
|
||||||
tooltip="Optional Camera Concepts to dictate camera motion via the Luma Concepts node.",
|
tooltip="Optional Camera Concepts to dictate camera motion via the Luma Concepts node.",
|
||||||
optional=True,
|
optional=True,
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
outputs=[IO.Video.Output()],
|
outputs=[IO.Video.Output()],
|
||||||
hidden=[
|
hidden=[
|
||||||
@ -519,24 +407,17 @@ class LumaTextToVideoGenerationNode(IO.ComfyNode):
|
|||||||
duration: str,
|
duration: str,
|
||||||
loop: bool,
|
loop: bool,
|
||||||
seed,
|
seed,
|
||||||
luma_concepts: LumaConceptChain = None,
|
luma_concepts: Optional[LumaConceptChain] = None,
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
validate_string(prompt, strip_whitespace=False, min_length=3)
|
validate_string(prompt, strip_whitespace=False, min_length=3)
|
||||||
duration = duration if model != LumaVideoModel.ray_1_6 else None
|
duration = duration if model != LumaVideoModel.ray_1_6 else None
|
||||||
resolution = resolution if model != LumaVideoModel.ray_1_6 else None
|
resolution = resolution if model != LumaVideoModel.ray_1_6 else None
|
||||||
|
|
||||||
auth_kwargs = {
|
response_api = await sync_op(
|
||||||
"auth_token": cls.hidden.auth_token_comfy_org,
|
cls,
|
||||||
"comfy_api_key": cls.hidden.api_key_comfy_org,
|
ApiEndpoint(path="/proxy/luma/generations", method="POST"),
|
||||||
}
|
response_model=LumaGeneration,
|
||||||
operation = SynchronousOperation(
|
data=LumaGenerationRequest(
|
||||||
endpoint=ApiEndpoint(
|
|
||||||
path="/proxy/luma/generations",
|
|
||||||
method=HttpMethod.POST,
|
|
||||||
request_model=LumaGenerationRequest,
|
|
||||||
response_model=LumaGeneration,
|
|
||||||
),
|
|
||||||
request=LumaGenerationRequest(
|
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=model,
|
model=model,
|
||||||
resolution=resolution,
|
resolution=resolution,
|
||||||
@ -545,47 +426,25 @@ class LumaTextToVideoGenerationNode(IO.ComfyNode):
|
|||||||
loop=loop,
|
loop=loop,
|
||||||
concepts=luma_concepts.create_api_model() if luma_concepts else None,
|
concepts=luma_concepts.create_api_model() if luma_concepts else None,
|
||||||
),
|
),
|
||||||
auth_kwargs=auth_kwargs,
|
|
||||||
)
|
)
|
||||||
response_api: LumaGeneration = await operation.execute()
|
response_poll = await poll_op(
|
||||||
|
cls,
|
||||||
if cls.hidden.unique_id:
|
ApiEndpoint(path=f"/proxy/luma/generations/{response_api.id}"),
|
||||||
PromptServer.instance.send_progress_text(f"Luma video generation started: {response_api.id}", cls.hidden.unique_id)
|
response_model=LumaGeneration,
|
||||||
|
|
||||||
operation = PollingOperation(
|
|
||||||
poll_endpoint=ApiEndpoint(
|
|
||||||
path=f"/proxy/luma/generations/{response_api.id}",
|
|
||||||
method=HttpMethod.GET,
|
|
||||||
request_model=EmptyRequest,
|
|
||||||
response_model=LumaGeneration,
|
|
||||||
),
|
|
||||||
completed_statuses=[LumaState.completed],
|
|
||||||
failed_statuses=[LumaState.failed],
|
|
||||||
status_extractor=lambda x: x.state,
|
status_extractor=lambda x: x.state,
|
||||||
result_url_extractor=video_result_url_extractor,
|
|
||||||
node_id=cls.hidden.unique_id,
|
|
||||||
estimated_duration=LUMA_T2V_AVERAGE_DURATION,
|
estimated_duration=LUMA_T2V_AVERAGE_DURATION,
|
||||||
auth_kwargs=auth_kwargs,
|
|
||||||
)
|
)
|
||||||
response_poll = await operation.execute()
|
return IO.NodeOutput(await download_url_to_video_output(response_poll.assets.video))
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(response_poll.assets.video) as vid_response:
|
|
||||||
return IO.NodeOutput(VideoFromFile(BytesIO(await vid_response.content.read())))
|
|
||||||
|
|
||||||
|
|
||||||
class LumaImageToVideoGenerationNode(IO.ComfyNode):
|
class LumaImageToVideoGenerationNode(IO.ComfyNode):
|
||||||
"""
|
|
||||||
Generates videos synchronously based on prompt, input images, and output_size.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls) -> IO.Schema:
|
def define_schema(cls) -> IO.Schema:
|
||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="LumaImageToVideoNode",
|
node_id="LumaImageToVideoNode",
|
||||||
display_name="Luma Image to Video",
|
display_name="Luma Image to Video",
|
||||||
category="api node/video/Luma",
|
category="api node/video/Luma",
|
||||||
description=cleandoc(cls.__doc__ or ""),
|
description="Generates videos synchronously based on prompt, input images, and output_size.",
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.String.Input(
|
IO.String.Input(
|
||||||
"prompt",
|
"prompt",
|
||||||
@ -637,7 +496,7 @@ class LumaImageToVideoGenerationNode(IO.ComfyNode):
|
|||||||
"luma_concepts",
|
"luma_concepts",
|
||||||
tooltip="Optional Camera Concepts to dictate camera motion via the Luma Concepts node.",
|
tooltip="Optional Camera Concepts to dictate camera motion via the Luma Concepts node.",
|
||||||
optional=True,
|
optional=True,
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
outputs=[IO.Video.Output()],
|
outputs=[IO.Video.Output()],
|
||||||
hidden=[
|
hidden=[
|
||||||
@ -662,25 +521,15 @@ class LumaImageToVideoGenerationNode(IO.ComfyNode):
|
|||||||
luma_concepts: LumaConceptChain = None,
|
luma_concepts: LumaConceptChain = None,
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
if first_image is None and last_image is None:
|
if first_image is None and last_image is None:
|
||||||
raise Exception(
|
raise Exception("At least one of first_image and last_image requires an input.")
|
||||||
"At least one of first_image and last_image requires an input."
|
keyframes = await cls._convert_to_keyframes(first_image, last_image)
|
||||||
)
|
|
||||||
auth_kwargs = {
|
|
||||||
"auth_token": cls.hidden.auth_token_comfy_org,
|
|
||||||
"comfy_api_key": cls.hidden.api_key_comfy_org,
|
|
||||||
}
|
|
||||||
keyframes = await cls._convert_to_keyframes(first_image, last_image, auth_kwargs=auth_kwargs)
|
|
||||||
duration = duration if model != LumaVideoModel.ray_1_6 else None
|
duration = duration if model != LumaVideoModel.ray_1_6 else None
|
||||||
resolution = resolution if model != LumaVideoModel.ray_1_6 else None
|
resolution = resolution if model != LumaVideoModel.ray_1_6 else None
|
||||||
|
response_api = await sync_op(
|
||||||
operation = SynchronousOperation(
|
cls,
|
||||||
endpoint=ApiEndpoint(
|
ApiEndpoint(path="/proxy/luma/generations", method="POST"),
|
||||||
path="/proxy/luma/generations",
|
response_model=LumaGeneration,
|
||||||
method=HttpMethod.POST,
|
data=LumaGenerationRequest(
|
||||||
request_model=LumaGenerationRequest,
|
|
||||||
response_model=LumaGeneration,
|
|
||||||
),
|
|
||||||
request=LumaGenerationRequest(
|
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=model,
|
model=model,
|
||||||
aspect_ratio=LumaAspectRatio.ratio_16_9, # ignored, but still needed by the API for some reason
|
aspect_ratio=LumaAspectRatio.ratio_16_9, # ignored, but still needed by the API for some reason
|
||||||
@ -690,54 +539,31 @@ class LumaImageToVideoGenerationNode(IO.ComfyNode):
|
|||||||
keyframes=keyframes,
|
keyframes=keyframes,
|
||||||
concepts=luma_concepts.create_api_model() if luma_concepts else None,
|
concepts=luma_concepts.create_api_model() if luma_concepts else None,
|
||||||
),
|
),
|
||||||
auth_kwargs=auth_kwargs,
|
|
||||||
)
|
)
|
||||||
response_api: LumaGeneration = await operation.execute()
|
response_poll = await poll_op(
|
||||||
|
cls,
|
||||||
if cls.hidden.unique_id:
|
poll_endpoint=ApiEndpoint(path=f"/proxy/luma/generations/{response_api.id}"),
|
||||||
PromptServer.instance.send_progress_text(f"Luma video generation started: {response_api.id}", cls.hidden.unique_id)
|
response_model=LumaGeneration,
|
||||||
|
|
||||||
operation = PollingOperation(
|
|
||||||
poll_endpoint=ApiEndpoint(
|
|
||||||
path=f"/proxy/luma/generations/{response_api.id}",
|
|
||||||
method=HttpMethod.GET,
|
|
||||||
request_model=EmptyRequest,
|
|
||||||
response_model=LumaGeneration,
|
|
||||||
),
|
|
||||||
completed_statuses=[LumaState.completed],
|
|
||||||
failed_statuses=[LumaState.failed],
|
|
||||||
status_extractor=lambda x: x.state,
|
status_extractor=lambda x: x.state,
|
||||||
result_url_extractor=video_result_url_extractor,
|
|
||||||
node_id=cls.hidden.unique_id,
|
|
||||||
estimated_duration=LUMA_I2V_AVERAGE_DURATION,
|
estimated_duration=LUMA_I2V_AVERAGE_DURATION,
|
||||||
auth_kwargs=auth_kwargs,
|
|
||||||
)
|
)
|
||||||
response_poll = await operation.execute()
|
return IO.NodeOutput(await download_url_to_video_output(response_poll.assets.video))
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(response_poll.assets.video) as vid_response:
|
|
||||||
return IO.NodeOutput(VideoFromFile(BytesIO(await vid_response.content.read())))
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _convert_to_keyframes(
|
async def _convert_to_keyframes(
|
||||||
cls,
|
cls,
|
||||||
first_image: torch.Tensor = None,
|
first_image: torch.Tensor = None,
|
||||||
last_image: torch.Tensor = None,
|
last_image: torch.Tensor = None,
|
||||||
auth_kwargs: Optional[dict[str,str]] = None,
|
|
||||||
):
|
):
|
||||||
if first_image is None and last_image is None:
|
if first_image is None and last_image is None:
|
||||||
return None
|
return None
|
||||||
frame0 = None
|
frame0 = None
|
||||||
frame1 = None
|
frame1 = None
|
||||||
if first_image is not None:
|
if first_image is not None:
|
||||||
download_urls = await upload_images_to_comfyapi(
|
download_urls = await upload_images_to_comfyapi(cls, first_image, max_images=1)
|
||||||
first_image, max_images=1, auth_kwargs=auth_kwargs,
|
|
||||||
)
|
|
||||||
frame0 = LumaImageReference(type="image", url=download_urls[0])
|
frame0 = LumaImageReference(type="image", url=download_urls[0])
|
||||||
if last_image is not None:
|
if last_image is not None:
|
||||||
download_urls = await upload_images_to_comfyapi(
|
download_urls = await upload_images_to_comfyapi(cls, last_image, max_images=1)
|
||||||
last_image, max_images=1, auth_kwargs=auth_kwargs,
|
|
||||||
)
|
|
||||||
frame1 = LumaImageReference(type="image", url=download_urls[0])
|
frame1 = LumaImageReference(type="image", url=download_urls[0])
|
||||||
return LumaKeyframes(frame0=frame0, frame1=frame1)
|
return LumaKeyframes(frame0=frame0, frame1=frame1)
|
||||||
|
|
||||||
|
|||||||
@ -1,71 +1,57 @@
|
|||||||
from inspect import cleandoc
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import logging
|
|
||||||
import torch
|
|
||||||
|
|
||||||
|
import torch
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
from comfy_api.latest import ComfyExtension, IO
|
|
||||||
from comfy_api.input_impl.video_types import VideoFromFile
|
from comfy_api.latest import IO, ComfyExtension
|
||||||
from comfy_api_nodes.apis import (
|
from comfy_api_nodes.apis.minimax_api import (
|
||||||
|
MinimaxFileRetrieveResponse,
|
||||||
|
MiniMaxModel,
|
||||||
|
MinimaxTaskResultResponse,
|
||||||
MinimaxVideoGenerationRequest,
|
MinimaxVideoGenerationRequest,
|
||||||
MinimaxVideoGenerationResponse,
|
MinimaxVideoGenerationResponse,
|
||||||
MinimaxFileRetrieveResponse,
|
|
||||||
MinimaxTaskResultResponse,
|
|
||||||
SubjectReferenceItem,
|
SubjectReferenceItem,
|
||||||
MiniMaxModel,
|
|
||||||
)
|
)
|
||||||
from comfy_api_nodes.apis.client import (
|
from comfy_api_nodes.util import (
|
||||||
ApiEndpoint,
|
ApiEndpoint,
|
||||||
HttpMethod,
|
download_url_to_video_output,
|
||||||
SynchronousOperation,
|
poll_op,
|
||||||
PollingOperation,
|
sync_op,
|
||||||
EmptyRequest,
|
|
||||||
)
|
|
||||||
from comfy_api_nodes.apinode_utils import (
|
|
||||||
download_url_to_bytesio,
|
|
||||||
upload_images_to_comfyapi,
|
upload_images_to_comfyapi,
|
||||||
|
validate_string,
|
||||||
)
|
)
|
||||||
from comfy_api_nodes.util import validate_string
|
|
||||||
from server import PromptServer
|
|
||||||
|
|
||||||
|
|
||||||
I2V_AVERAGE_DURATION = 114
|
I2V_AVERAGE_DURATION = 114
|
||||||
T2V_AVERAGE_DURATION = 234
|
T2V_AVERAGE_DURATION = 234
|
||||||
|
|
||||||
|
|
||||||
async def _generate_mm_video(
|
async def _generate_mm_video(
|
||||||
|
cls: type[IO.ComfyNode],
|
||||||
*,
|
*,
|
||||||
auth: dict[str, str],
|
|
||||||
node_id: str,
|
|
||||||
prompt_text: str,
|
prompt_text: str,
|
||||||
seed: int,
|
seed: int,
|
||||||
model: str,
|
model: str,
|
||||||
image: Optional[torch.Tensor] = None, # used for ImageToVideo
|
image: Optional[torch.Tensor] = None, # used for ImageToVideo
|
||||||
subject: Optional[torch.Tensor] = None, # used for SubjectToVideo
|
subject: Optional[torch.Tensor] = None, # used for SubjectToVideo
|
||||||
average_duration: Optional[int] = None,
|
average_duration: Optional[int] = None,
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
if image is None:
|
if image is None:
|
||||||
validate_string(prompt_text, field_name="prompt_text")
|
validate_string(prompt_text, field_name="prompt_text")
|
||||||
# upload image, if passed in
|
|
||||||
image_url = None
|
image_url = None
|
||||||
if image is not None:
|
if image is not None:
|
||||||
image_url = (await upload_images_to_comfyapi(image, max_images=1, auth_kwargs=auth))[0]
|
image_url = (await upload_images_to_comfyapi(cls, image, max_images=1))[0]
|
||||||
|
|
||||||
# TODO: figure out how to deal with subject properly, API returns invalid params when using S2V-01 model
|
# TODO: figure out how to deal with subject properly, API returns invalid params when using S2V-01 model
|
||||||
subject_reference = None
|
subject_reference = None
|
||||||
if subject is not None:
|
if subject is not None:
|
||||||
subject_url = (await upload_images_to_comfyapi(subject, max_images=1, auth_kwargs=auth))[0]
|
subject_url = (await upload_images_to_comfyapi(cls, subject, max_images=1))[0]
|
||||||
subject_reference = [SubjectReferenceItem(image=subject_url)]
|
subject_reference = [SubjectReferenceItem(image=subject_url)]
|
||||||
|
|
||||||
|
response = await sync_op(
|
||||||
video_generate_operation = SynchronousOperation(
|
cls,
|
||||||
endpoint=ApiEndpoint(
|
ApiEndpoint(path="/proxy/minimax/video_generation", method="POST"),
|
||||||
path="/proxy/minimax/video_generation",
|
response_model=MinimaxVideoGenerationResponse,
|
||||||
method=HttpMethod.POST,
|
data=MinimaxVideoGenerationRequest(
|
||||||
request_model=MinimaxVideoGenerationRequest,
|
|
||||||
response_model=MinimaxVideoGenerationResponse,
|
|
||||||
),
|
|
||||||
request=MinimaxVideoGenerationRequest(
|
|
||||||
model=MiniMaxModel(model),
|
model=MiniMaxModel(model),
|
||||||
prompt=prompt_text,
|
prompt=prompt_text,
|
||||||
callback_url=None,
|
callback_url=None,
|
||||||
@ -73,81 +59,50 @@ async def _generate_mm_video(
|
|||||||
subject_reference=subject_reference,
|
subject_reference=subject_reference,
|
||||||
prompt_optimizer=None,
|
prompt_optimizer=None,
|
||||||
),
|
),
|
||||||
auth_kwargs=auth,
|
|
||||||
)
|
)
|
||||||
response = await video_generate_operation.execute()
|
|
||||||
|
|
||||||
task_id = response.task_id
|
task_id = response.task_id
|
||||||
if not task_id:
|
if not task_id:
|
||||||
raise Exception(f"MiniMax generation failed: {response.base_resp}")
|
raise Exception(f"MiniMax generation failed: {response.base_resp}")
|
||||||
|
|
||||||
video_generate_operation = PollingOperation(
|
task_result = await poll_op(
|
||||||
poll_endpoint=ApiEndpoint(
|
cls,
|
||||||
path="/proxy/minimax/query/video_generation",
|
ApiEndpoint(path="/proxy/minimax/query/video_generation", query_params={"task_id": task_id}),
|
||||||
method=HttpMethod.GET,
|
response_model=MinimaxTaskResultResponse,
|
||||||
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,
|
status_extractor=lambda x: x.status.value,
|
||||||
estimated_duration=average_duration,
|
estimated_duration=average_duration,
|
||||||
node_id=node_id,
|
|
||||||
auth_kwargs=auth,
|
|
||||||
)
|
)
|
||||||
task_result = await video_generate_operation.execute()
|
|
||||||
|
|
||||||
file_id = task_result.file_id
|
file_id = task_result.file_id
|
||||||
if file_id is None:
|
if file_id is None:
|
||||||
raise Exception("Request was not successful. Missing file ID.")
|
raise Exception("Request was not successful. Missing file ID.")
|
||||||
file_retrieve_operation = SynchronousOperation(
|
file_result = await sync_op(
|
||||||
endpoint=ApiEndpoint(
|
cls,
|
||||||
path="/proxy/minimax/files/retrieve",
|
ApiEndpoint(path="/proxy/minimax/files/retrieve", query_params={"file_id": int(file_id)}),
|
||||||
method=HttpMethod.GET,
|
response_model=MinimaxFileRetrieveResponse,
|
||||||
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
|
file_url = file_result.file.download_url
|
||||||
if file_url is None:
|
if file_url is None:
|
||||||
raise Exception(
|
raise Exception(f"No video was found in the response. Full response: {file_result.model_dump()}")
|
||||||
f"No video was found in the response. Full response: {file_result.model_dump()}"
|
if file_result.file.backup_download_url:
|
||||||
)
|
try:
|
||||||
logging.info("Generated video URL: %s", file_url)
|
return IO.NodeOutput(await download_url_to_video_output(file_url, timeout=10, max_retries=2))
|
||||||
if node_id:
|
except Exception: # if we have a second URL to retrieve the result, try again using that one
|
||||||
if hasattr(file_result.file, "backup_download_url"):
|
return IO.NodeOutput(
|
||||||
message = f"Result URL: {file_url}\nBackup URL: {file_result.file.backup_download_url}"
|
await download_url_to_video_output(file_result.file.backup_download_url, max_retries=3)
|
||||||
else:
|
)
|
||||||
message = f"Result URL: {file_url}"
|
return IO.NodeOutput(await download_url_to_video_output(file_url))
|
||||||
PromptServer.instance.send_progress_text(message, node_id)
|
|
||||||
|
|
||||||
# Download and return as VideoFromFile
|
|
||||||
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 IO.NodeOutput(VideoFromFile(video_io))
|
|
||||||
|
|
||||||
|
|
||||||
class MinimaxTextToVideoNode(IO.ComfyNode):
|
class MinimaxTextToVideoNode(IO.ComfyNode):
|
||||||
"""
|
|
||||||
Generates videos synchronously based on a prompt, and optional parameters using MiniMax's API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls) -> IO.Schema:
|
def define_schema(cls) -> IO.Schema:
|
||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="MinimaxTextToVideoNode",
|
node_id="MinimaxTextToVideoNode",
|
||||||
display_name="MiniMax Text to Video",
|
display_name="MiniMax Text to Video",
|
||||||
category="api node/video/MiniMax",
|
category="api node/video/MiniMax",
|
||||||
description=cleandoc(cls.__doc__ or ""),
|
description="Generates videos synchronously based on a prompt, and optional parameters.",
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.String.Input(
|
IO.String.Input(
|
||||||
"prompt_text",
|
"prompt_text",
|
||||||
@ -189,11 +144,7 @@ class MinimaxTextToVideoNode(IO.ComfyNode):
|
|||||||
seed: int = 0,
|
seed: int = 0,
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
return await _generate_mm_video(
|
return await _generate_mm_video(
|
||||||
auth={
|
cls,
|
||||||
"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,
|
prompt_text=prompt_text,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
model=model,
|
model=model,
|
||||||
@ -204,17 +155,13 @@ class MinimaxTextToVideoNode(IO.ComfyNode):
|
|||||||
|
|
||||||
|
|
||||||
class MinimaxImageToVideoNode(IO.ComfyNode):
|
class MinimaxImageToVideoNode(IO.ComfyNode):
|
||||||
"""
|
|
||||||
Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls) -> IO.Schema:
|
def define_schema(cls) -> IO.Schema:
|
||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="MinimaxImageToVideoNode",
|
node_id="MinimaxImageToVideoNode",
|
||||||
display_name="MiniMax Image to Video",
|
display_name="MiniMax Image to Video",
|
||||||
category="api node/video/MiniMax",
|
category="api node/video/MiniMax",
|
||||||
description=cleandoc(cls.__doc__ or ""),
|
description="Generates videos synchronously based on an image and prompt, and optional parameters.",
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.Image.Input(
|
IO.Image.Input(
|
||||||
"image",
|
"image",
|
||||||
@ -261,11 +208,7 @@ class MinimaxImageToVideoNode(IO.ComfyNode):
|
|||||||
seed: int = 0,
|
seed: int = 0,
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
return await _generate_mm_video(
|
return await _generate_mm_video(
|
||||||
auth={
|
cls,
|
||||||
"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,
|
prompt_text=prompt_text,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
model=model,
|
model=model,
|
||||||
@ -276,17 +219,13 @@ class MinimaxImageToVideoNode(IO.ComfyNode):
|
|||||||
|
|
||||||
|
|
||||||
class MinimaxSubjectToVideoNode(IO.ComfyNode):
|
class MinimaxSubjectToVideoNode(IO.ComfyNode):
|
||||||
"""
|
|
||||||
Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls) -> IO.Schema:
|
def define_schema(cls) -> IO.Schema:
|
||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="MinimaxSubjectToVideoNode",
|
node_id="MinimaxSubjectToVideoNode",
|
||||||
display_name="MiniMax Subject to Video",
|
display_name="MiniMax Subject to Video",
|
||||||
category="api node/video/MiniMax",
|
category="api node/video/MiniMax",
|
||||||
description=cleandoc(cls.__doc__ or ""),
|
description="Generates videos synchronously based on an image and prompt, and optional parameters.",
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.Image.Input(
|
IO.Image.Input(
|
||||||
"subject",
|
"subject",
|
||||||
@ -333,11 +272,7 @@ class MinimaxSubjectToVideoNode(IO.ComfyNode):
|
|||||||
seed: int = 0,
|
seed: int = 0,
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
return await _generate_mm_video(
|
return await _generate_mm_video(
|
||||||
auth={
|
cls,
|
||||||
"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,
|
prompt_text=prompt_text,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
model=model,
|
model=model,
|
||||||
@ -348,15 +283,13 @@ class MinimaxSubjectToVideoNode(IO.ComfyNode):
|
|||||||
|
|
||||||
|
|
||||||
class MinimaxHailuoVideoNode(IO.ComfyNode):
|
class MinimaxHailuoVideoNode(IO.ComfyNode):
|
||||||
"""Generates videos from prompt, with optional start frame using the new MiniMax Hailuo-02 model."""
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls) -> IO.Schema:
|
def define_schema(cls) -> IO.Schema:
|
||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="MinimaxHailuoVideoNode",
|
node_id="MinimaxHailuoVideoNode",
|
||||||
display_name="MiniMax Hailuo Video",
|
display_name="MiniMax Hailuo Video",
|
||||||
category="api node/video/MiniMax",
|
category="api node/video/MiniMax",
|
||||||
description=cleandoc(cls.__doc__ or ""),
|
description="Generates videos from prompt, with optional start frame using the new MiniMax Hailuo-02 model.",
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.String.Input(
|
IO.String.Input(
|
||||||
"prompt_text",
|
"prompt_text",
|
||||||
@ -420,10 +353,6 @@ class MinimaxHailuoVideoNode(IO.ComfyNode):
|
|||||||
resolution: str = "768P",
|
resolution: str = "768P",
|
||||||
model: str = "MiniMax-Hailuo-02",
|
model: str = "MiniMax-Hailuo-02",
|
||||||
) -> IO.NodeOutput:
|
) -> 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:
|
if first_frame_image is None:
|
||||||
validate_string(prompt_text, field_name="prompt_text")
|
validate_string(prompt_text, field_name="prompt_text")
|
||||||
|
|
||||||
@ -435,16 +364,13 @@ class MinimaxHailuoVideoNode(IO.ComfyNode):
|
|||||||
# upload image, if passed in
|
# upload image, if passed in
|
||||||
image_url = None
|
image_url = None
|
||||||
if first_frame_image is not 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]
|
image_url = (await upload_images_to_comfyapi(cls, first_frame_image, max_images=1))[0]
|
||||||
|
|
||||||
video_generate_operation = SynchronousOperation(
|
response = await sync_op(
|
||||||
endpoint=ApiEndpoint(
|
cls,
|
||||||
path="/proxy/minimax/video_generation",
|
ApiEndpoint(path="/proxy/minimax/video_generation", method="POST"),
|
||||||
method=HttpMethod.POST,
|
response_model=MinimaxVideoGenerationResponse,
|
||||||
request_model=MinimaxVideoGenerationRequest,
|
data=MinimaxVideoGenerationRequest(
|
||||||
response_model=MinimaxVideoGenerationResponse,
|
|
||||||
),
|
|
||||||
request=MinimaxVideoGenerationRequest(
|
|
||||||
model=MiniMaxModel(model),
|
model=MiniMaxModel(model),
|
||||||
prompt=prompt_text,
|
prompt=prompt_text,
|
||||||
callback_url=None,
|
callback_url=None,
|
||||||
@ -453,67 +379,42 @@ class MinimaxHailuoVideoNode(IO.ComfyNode):
|
|||||||
duration=duration,
|
duration=duration,
|
||||||
resolution=resolution,
|
resolution=resolution,
|
||||||
),
|
),
|
||||||
auth_kwargs=auth,
|
|
||||||
)
|
)
|
||||||
response = await video_generate_operation.execute()
|
|
||||||
|
|
||||||
task_id = response.task_id
|
task_id = response.task_id
|
||||||
if not task_id:
|
if not task_id:
|
||||||
raise Exception(f"MiniMax generation failed: {response.base_resp}")
|
raise Exception(f"MiniMax generation failed: {response.base_resp}")
|
||||||
|
|
||||||
average_duration = 120 if resolution == "768P" else 240
|
average_duration = 120 if resolution == "768P" else 240
|
||||||
video_generate_operation = PollingOperation(
|
task_result = await poll_op(
|
||||||
poll_endpoint=ApiEndpoint(
|
cls,
|
||||||
path="/proxy/minimax/query/video_generation",
|
ApiEndpoint(path="/proxy/minimax/query/video_generation", query_params={"task_id": task_id}),
|
||||||
method=HttpMethod.GET,
|
response_model=MinimaxTaskResultResponse,
|
||||||
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,
|
status_extractor=lambda x: x.status.value,
|
||||||
estimated_duration=average_duration,
|
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
|
file_id = task_result.file_id
|
||||||
if file_id is None:
|
if file_id is None:
|
||||||
raise Exception("Request was not successful. Missing file ID.")
|
raise Exception("Request was not successful. Missing file ID.")
|
||||||
file_retrieve_operation = SynchronousOperation(
|
file_result = await sync_op(
|
||||||
endpoint=ApiEndpoint(
|
cls,
|
||||||
path="/proxy/minimax/files/retrieve",
|
ApiEndpoint(path="/proxy/minimax/files/retrieve", query_params={"file_id": int(file_id)}),
|
||||||
method=HttpMethod.GET,
|
response_model=MinimaxFileRetrieveResponse,
|
||||||
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
|
file_url = file_result.file.download_url
|
||||||
if file_url is None:
|
if file_url is None:
|
||||||
raise Exception(
|
raise Exception(f"No video was found in the response. Full response: {file_result.model_dump()}")
|
||||||
f"No video was found in the response. Full response: {file_result.model_dump()}"
|
|
||||||
)
|
|
||||||
logging.info("Generated video URL: %s", 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 file_result.file.backup_download_url:
|
||||||
if video_io is None:
|
try:
|
||||||
error_msg = f"Failed to download video from {file_url}"
|
return IO.NodeOutput(await download_url_to_video_output(file_url, timeout=10, max_retries=2))
|
||||||
logging.error(error_msg)
|
except Exception: # if we have a second URL to retrieve the result, try again using that one
|
||||||
raise Exception(error_msg)
|
return IO.NodeOutput(
|
||||||
return IO.NodeOutput(VideoFromFile(video_io))
|
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 MinimaxExtension(ComfyExtension):
|
class MinimaxExtension(ComfyExtension):
|
||||||
|
|||||||
@ -78,7 +78,7 @@ class _PollUIState:
|
|||||||
|
|
||||||
_RETRY_STATUS = {408, 429, 500, 502, 503, 504}
|
_RETRY_STATUS = {408, 429, 500, 502, 503, 504}
|
||||||
COMPLETED_STATUSES = ["succeeded", "succeed", "success", "completed"]
|
COMPLETED_STATUSES = ["succeeded", "succeed", "success", "completed"]
|
||||||
FAILED_STATUSES = ["cancelled", "canceled", "failed", "error"]
|
FAILED_STATUSES = ["cancelled", "canceled", "fail", "failed", "error"]
|
||||||
QUEUED_STATUSES = ["created", "queued", "queueing", "submitted"]
|
QUEUED_STATUSES = ["created", "queued", "queueing", "submitted"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -232,11 +232,12 @@ async def download_url_to_video_output(
|
|||||||
video_url: str,
|
video_url: str,
|
||||||
*,
|
*,
|
||||||
timeout: float = None,
|
timeout: float = None,
|
||||||
|
max_retries: int = 5,
|
||||||
cls: type[COMFY_IO.ComfyNode] = None,
|
cls: type[COMFY_IO.ComfyNode] = None,
|
||||||
) -> VideoFromFile:
|
) -> VideoFromFile:
|
||||||
"""Downloads a video from a URL and returns a `VIDEO` output."""
|
"""Downloads a video from a URL and returns a `VIDEO` output."""
|
||||||
result = BytesIO()
|
result = BytesIO()
|
||||||
await download_url_to_bytesio(video_url, result, timeout=timeout, cls=cls)
|
await download_url_to_bytesio(video_url, result, timeout=timeout, max_retries=max_retries, cls=cls)
|
||||||
return VideoFromFile(result)
|
return VideoFromFile(result)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user