|
|
from inspect import cleandoc |
|
|
from typing import Optional |
|
|
from typing_extensions import override |
|
|
from io import BytesIO |
|
|
from comfy_api_nodes.apis.pixverse_api import ( |
|
|
PixverseTextVideoRequest, |
|
|
PixverseImageVideoRequest, |
|
|
PixverseTransitionVideoRequest, |
|
|
PixverseImageUploadResponse, |
|
|
PixverseVideoResponse, |
|
|
PixverseGenerationStatusResponse, |
|
|
PixverseAspectRatio, |
|
|
PixverseQuality, |
|
|
PixverseDuration, |
|
|
PixverseMotionMode, |
|
|
PixverseStatus, |
|
|
PixverseIO, |
|
|
pixverse_templates, |
|
|
) |
|
|
from comfy_api_nodes.apis.client import ( |
|
|
ApiEndpoint, |
|
|
HttpMethod, |
|
|
SynchronousOperation, |
|
|
PollingOperation, |
|
|
EmptyRequest, |
|
|
) |
|
|
from comfy_api_nodes.util import validate_string, tensor_to_bytesio |
|
|
from comfy_api.input_impl import VideoFromFile |
|
|
from comfy_api.latest import ComfyExtension, IO |
|
|
|
|
|
import torch |
|
|
import aiohttp |
|
|
|
|
|
|
|
|
AVERAGE_DURATION_T2V = 32 |
|
|
AVERAGE_DURATION_I2V = 30 |
|
|
AVERAGE_DURATION_T2T = 52 |
|
|
|
|
|
|
|
|
def get_video_url_from_response( |
|
|
response: PixverseGenerationStatusResponse, |
|
|
) -> Optional[str]: |
|
|
if response.Resp is None or response.Resp.url is None: |
|
|
return None |
|
|
return str(response.Resp.url) |
|
|
|
|
|
|
|
|
async def upload_image_to_pixverse(image: torch.Tensor, auth_kwargs=None): |
|
|
|
|
|
operation = SynchronousOperation( |
|
|
endpoint=ApiEndpoint( |
|
|
path="/proxy/pixverse/image/upload", |
|
|
method=HttpMethod.POST, |
|
|
request_model=EmptyRequest, |
|
|
response_model=PixverseImageUploadResponse, |
|
|
), |
|
|
request=EmptyRequest(), |
|
|
files={"image": tensor_to_bytesio(image)}, |
|
|
content_type="multipart/form-data", |
|
|
auth_kwargs=auth_kwargs, |
|
|
) |
|
|
response_upload: PixverseImageUploadResponse = await operation.execute() |
|
|
|
|
|
if response_upload.Resp is None: |
|
|
raise Exception(f"PixVerse image upload request failed: '{response_upload.ErrMsg}'") |
|
|
|
|
|
return response_upload.Resp.img_id |
|
|
|
|
|
|
|
|
class PixverseTemplateNode(IO.ComfyNode): |
|
|
""" |
|
|
Select template for PixVerse Video generation. |
|
|
""" |
|
|
|
|
|
@classmethod |
|
|
def define_schema(cls) -> IO.Schema: |
|
|
return IO.Schema( |
|
|
node_id="PixverseTemplateNode", |
|
|
display_name="PixVerse Template", |
|
|
category="api node/video/PixVerse", |
|
|
inputs=[ |
|
|
IO.Combo.Input("template", options=list(pixverse_templates.keys())), |
|
|
], |
|
|
outputs=[IO.Custom(PixverseIO.TEMPLATE).Output(display_name="pixverse_template")], |
|
|
) |
|
|
|
|
|
@classmethod |
|
|
def execute(cls, template: str) -> IO.NodeOutput: |
|
|
template_id = pixverse_templates.get(template, None) |
|
|
if template_id is None: |
|
|
raise Exception(f"Template '{template}' is not recognized.") |
|
|
return IO.NodeOutput(template_id) |
|
|
|
|
|
|
|
|
class PixverseTextToVideoNode(IO.ComfyNode): |
|
|
""" |
|
|
Generates videos based on prompt and output_size. |
|
|
""" |
|
|
|
|
|
@classmethod |
|
|
def define_schema(cls) -> IO.Schema: |
|
|
return IO.Schema( |
|
|
node_id="PixverseTextToVideoNode", |
|
|
display_name="PixVerse Text to Video", |
|
|
category="api node/video/PixVerse", |
|
|
description=cleandoc(cls.__doc__ or ""), |
|
|
inputs=[ |
|
|
IO.String.Input( |
|
|
"prompt", |
|
|
multiline=True, |
|
|
default="", |
|
|
tooltip="Prompt for the video generation", |
|
|
), |
|
|
IO.Combo.Input( |
|
|
"aspect_ratio", |
|
|
options=PixverseAspectRatio, |
|
|
), |
|
|
IO.Combo.Input( |
|
|
"quality", |
|
|
options=PixverseQuality, |
|
|
default=PixverseQuality.res_540p, |
|
|
), |
|
|
IO.Combo.Input( |
|
|
"duration_seconds", |
|
|
options=PixverseDuration, |
|
|
), |
|
|
IO.Combo.Input( |
|
|
"motion_mode", |
|
|
options=PixverseMotionMode, |
|
|
), |
|
|
IO.Int.Input( |
|
|
"seed", |
|
|
default=0, |
|
|
min=0, |
|
|
max=2147483647, |
|
|
control_after_generate=True, |
|
|
tooltip="Seed for video generation.", |
|
|
), |
|
|
IO.String.Input( |
|
|
"negative_prompt", |
|
|
default="", |
|
|
multiline=True, |
|
|
tooltip="An optional text description of undesired elements on an image.", |
|
|
optional=True, |
|
|
), |
|
|
IO.Custom(PixverseIO.TEMPLATE).Input( |
|
|
"pixverse_template", |
|
|
tooltip="An optional template to influence style of generation, created by the PixVerse Template node.", |
|
|
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, |
|
|
prompt: str, |
|
|
aspect_ratio: str, |
|
|
quality: str, |
|
|
duration_seconds: int, |
|
|
motion_mode: str, |
|
|
seed, |
|
|
negative_prompt: str = None, |
|
|
pixverse_template: int = None, |
|
|
) -> IO.NodeOutput: |
|
|
validate_string(prompt, strip_whitespace=False) |
|
|
|
|
|
|
|
|
if quality == PixverseQuality.res_1080p: |
|
|
motion_mode = PixverseMotionMode.normal |
|
|
duration_seconds = PixverseDuration.dur_5 |
|
|
elif duration_seconds != PixverseDuration.dur_5: |
|
|
motion_mode = PixverseMotionMode.normal |
|
|
|
|
|
auth = { |
|
|
"auth_token": cls.hidden.auth_token_comfy_org, |
|
|
"comfy_api_key": cls.hidden.api_key_comfy_org, |
|
|
} |
|
|
operation = SynchronousOperation( |
|
|
endpoint=ApiEndpoint( |
|
|
path="/proxy/pixverse/video/text/generate", |
|
|
method=HttpMethod.POST, |
|
|
request_model=PixverseTextVideoRequest, |
|
|
response_model=PixverseVideoResponse, |
|
|
), |
|
|
request=PixverseTextVideoRequest( |
|
|
prompt=prompt, |
|
|
aspect_ratio=aspect_ratio, |
|
|
quality=quality, |
|
|
duration=duration_seconds, |
|
|
motion_mode=motion_mode, |
|
|
negative_prompt=negative_prompt if negative_prompt else None, |
|
|
template_id=pixverse_template, |
|
|
seed=seed, |
|
|
), |
|
|
auth_kwargs=auth, |
|
|
) |
|
|
response_api = await operation.execute() |
|
|
|
|
|
if response_api.Resp is None: |
|
|
raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'") |
|
|
|
|
|
operation = PollingOperation( |
|
|
poll_endpoint=ApiEndpoint( |
|
|
path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}", |
|
|
method=HttpMethod.GET, |
|
|
request_model=EmptyRequest, |
|
|
response_model=PixverseGenerationStatusResponse, |
|
|
), |
|
|
completed_statuses=[PixverseStatus.successful], |
|
|
failed_statuses=[ |
|
|
PixverseStatus.contents_moderation, |
|
|
PixverseStatus.failed, |
|
|
PixverseStatus.deleted, |
|
|
], |
|
|
status_extractor=lambda x: x.Resp.status, |
|
|
auth_kwargs=auth, |
|
|
node_id=cls.hidden.unique_id, |
|
|
result_url_extractor=get_video_url_from_response, |
|
|
estimated_duration=AVERAGE_DURATION_T2V, |
|
|
) |
|
|
response_poll = await operation.execute() |
|
|
|
|
|
async with aiohttp.ClientSession() as session: |
|
|
async with session.get(response_poll.Resp.url) as vid_response: |
|
|
return IO.NodeOutput(VideoFromFile(BytesIO(await vid_response.content.read()))) |
|
|
|
|
|
|
|
|
class PixverseImageToVideoNode(IO.ComfyNode): |
|
|
""" |
|
|
Generates videos based on prompt and output_size. |
|
|
""" |
|
|
|
|
|
@classmethod |
|
|
def define_schema(cls) -> IO.Schema: |
|
|
return IO.Schema( |
|
|
node_id="PixverseImageToVideoNode", |
|
|
display_name="PixVerse Image to Video", |
|
|
category="api node/video/PixVerse", |
|
|
description=cleandoc(cls.__doc__ or ""), |
|
|
inputs=[ |
|
|
IO.Image.Input("image"), |
|
|
IO.String.Input( |
|
|
"prompt", |
|
|
multiline=True, |
|
|
default="", |
|
|
tooltip="Prompt for the video generation", |
|
|
), |
|
|
IO.Combo.Input( |
|
|
"quality", |
|
|
options=PixverseQuality, |
|
|
default=PixverseQuality.res_540p, |
|
|
), |
|
|
IO.Combo.Input( |
|
|
"duration_seconds", |
|
|
options=PixverseDuration, |
|
|
), |
|
|
IO.Combo.Input( |
|
|
"motion_mode", |
|
|
options=PixverseMotionMode, |
|
|
), |
|
|
IO.Int.Input( |
|
|
"seed", |
|
|
default=0, |
|
|
min=0, |
|
|
max=2147483647, |
|
|
control_after_generate=True, |
|
|
tooltip="Seed for video generation.", |
|
|
), |
|
|
IO.String.Input( |
|
|
"negative_prompt", |
|
|
default="", |
|
|
multiline=True, |
|
|
tooltip="An optional text description of undesired elements on an image.", |
|
|
optional=True, |
|
|
), |
|
|
IO.Custom(PixverseIO.TEMPLATE).Input( |
|
|
"pixverse_template", |
|
|
tooltip="An optional template to influence style of generation, created by the PixVerse Template node.", |
|
|
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, |
|
|
image: torch.Tensor, |
|
|
prompt: str, |
|
|
quality: str, |
|
|
duration_seconds: int, |
|
|
motion_mode: str, |
|
|
seed, |
|
|
negative_prompt: str = None, |
|
|
pixverse_template: int = None, |
|
|
) -> IO.NodeOutput: |
|
|
validate_string(prompt, strip_whitespace=False) |
|
|
auth = { |
|
|
"auth_token": cls.hidden.auth_token_comfy_org, |
|
|
"comfy_api_key": cls.hidden.api_key_comfy_org, |
|
|
} |
|
|
img_id = await upload_image_to_pixverse(image, auth_kwargs=auth) |
|
|
|
|
|
|
|
|
|
|
|
if quality == PixverseQuality.res_1080p: |
|
|
motion_mode = PixverseMotionMode.normal |
|
|
duration_seconds = PixverseDuration.dur_5 |
|
|
elif duration_seconds != PixverseDuration.dur_5: |
|
|
motion_mode = PixverseMotionMode.normal |
|
|
|
|
|
operation = SynchronousOperation( |
|
|
endpoint=ApiEndpoint( |
|
|
path="/proxy/pixverse/video/img/generate", |
|
|
method=HttpMethod.POST, |
|
|
request_model=PixverseImageVideoRequest, |
|
|
response_model=PixverseVideoResponse, |
|
|
), |
|
|
request=PixverseImageVideoRequest( |
|
|
img_id=img_id, |
|
|
prompt=prompt, |
|
|
quality=quality, |
|
|
duration=duration_seconds, |
|
|
motion_mode=motion_mode, |
|
|
negative_prompt=negative_prompt if negative_prompt else None, |
|
|
template_id=pixverse_template, |
|
|
seed=seed, |
|
|
), |
|
|
auth_kwargs=auth, |
|
|
) |
|
|
response_api = await operation.execute() |
|
|
|
|
|
if response_api.Resp is None: |
|
|
raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'") |
|
|
|
|
|
operation = PollingOperation( |
|
|
poll_endpoint=ApiEndpoint( |
|
|
path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}", |
|
|
method=HttpMethod.GET, |
|
|
request_model=EmptyRequest, |
|
|
response_model=PixverseGenerationStatusResponse, |
|
|
), |
|
|
completed_statuses=[PixverseStatus.successful], |
|
|
failed_statuses=[ |
|
|
PixverseStatus.contents_moderation, |
|
|
PixverseStatus.failed, |
|
|
PixverseStatus.deleted, |
|
|
], |
|
|
status_extractor=lambda x: x.Resp.status, |
|
|
auth_kwargs=auth, |
|
|
node_id=cls.hidden.unique_id, |
|
|
result_url_extractor=get_video_url_from_response, |
|
|
estimated_duration=AVERAGE_DURATION_I2V, |
|
|
) |
|
|
response_poll = await operation.execute() |
|
|
|
|
|
async with aiohttp.ClientSession() as session: |
|
|
async with session.get(response_poll.Resp.url) as vid_response: |
|
|
return IO.NodeOutput(VideoFromFile(BytesIO(await vid_response.content.read()))) |
|
|
|
|
|
|
|
|
class PixverseTransitionVideoNode(IO.ComfyNode): |
|
|
""" |
|
|
Generates videos based on prompt and output_size. |
|
|
""" |
|
|
|
|
|
@classmethod |
|
|
def define_schema(cls) -> IO.Schema: |
|
|
return IO.Schema( |
|
|
node_id="PixverseTransitionVideoNode", |
|
|
display_name="PixVerse Transition Video", |
|
|
category="api node/video/PixVerse", |
|
|
description=cleandoc(cls.__doc__ or ""), |
|
|
inputs=[ |
|
|
IO.Image.Input("first_frame"), |
|
|
IO.Image.Input("last_frame"), |
|
|
IO.String.Input( |
|
|
"prompt", |
|
|
multiline=True, |
|
|
default="", |
|
|
tooltip="Prompt for the video generation", |
|
|
), |
|
|
IO.Combo.Input( |
|
|
"quality", |
|
|
options=PixverseQuality, |
|
|
default=PixverseQuality.res_540p, |
|
|
), |
|
|
IO.Combo.Input( |
|
|
"duration_seconds", |
|
|
options=PixverseDuration, |
|
|
), |
|
|
IO.Combo.Input( |
|
|
"motion_mode", |
|
|
options=PixverseMotionMode, |
|
|
), |
|
|
IO.Int.Input( |
|
|
"seed", |
|
|
default=0, |
|
|
min=0, |
|
|
max=2147483647, |
|
|
control_after_generate=True, |
|
|
tooltip="Seed for video generation.", |
|
|
), |
|
|
IO.String.Input( |
|
|
"negative_prompt", |
|
|
default="", |
|
|
multiline=True, |
|
|
tooltip="An optional text description of undesired elements on an image.", |
|
|
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, |
|
|
first_frame: torch.Tensor, |
|
|
last_frame: torch.Tensor, |
|
|
prompt: str, |
|
|
quality: str, |
|
|
duration_seconds: int, |
|
|
motion_mode: str, |
|
|
seed, |
|
|
negative_prompt: str = None, |
|
|
) -> IO.NodeOutput: |
|
|
validate_string(prompt, strip_whitespace=False) |
|
|
auth = { |
|
|
"auth_token": cls.hidden.auth_token_comfy_org, |
|
|
"comfy_api_key": cls.hidden.api_key_comfy_org, |
|
|
} |
|
|
first_frame_id = await upload_image_to_pixverse(first_frame, auth_kwargs=auth) |
|
|
last_frame_id = await upload_image_to_pixverse(last_frame, auth_kwargs=auth) |
|
|
|
|
|
|
|
|
|
|
|
if quality == PixverseQuality.res_1080p: |
|
|
motion_mode = PixverseMotionMode.normal |
|
|
duration_seconds = PixverseDuration.dur_5 |
|
|
elif duration_seconds != PixverseDuration.dur_5: |
|
|
motion_mode = PixverseMotionMode.normal |
|
|
|
|
|
operation = SynchronousOperation( |
|
|
endpoint=ApiEndpoint( |
|
|
path="/proxy/pixverse/video/transition/generate", |
|
|
method=HttpMethod.POST, |
|
|
request_model=PixverseTransitionVideoRequest, |
|
|
response_model=PixverseVideoResponse, |
|
|
), |
|
|
request=PixverseTransitionVideoRequest( |
|
|
first_frame_img=first_frame_id, |
|
|
last_frame_img=last_frame_id, |
|
|
prompt=prompt, |
|
|
quality=quality, |
|
|
duration=duration_seconds, |
|
|
motion_mode=motion_mode, |
|
|
negative_prompt=negative_prompt if negative_prompt else None, |
|
|
seed=seed, |
|
|
), |
|
|
auth_kwargs=auth, |
|
|
) |
|
|
response_api = await operation.execute() |
|
|
|
|
|
if response_api.Resp is None: |
|
|
raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'") |
|
|
|
|
|
operation = PollingOperation( |
|
|
poll_endpoint=ApiEndpoint( |
|
|
path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}", |
|
|
method=HttpMethod.GET, |
|
|
request_model=EmptyRequest, |
|
|
response_model=PixverseGenerationStatusResponse, |
|
|
), |
|
|
completed_statuses=[PixverseStatus.successful], |
|
|
failed_statuses=[ |
|
|
PixverseStatus.contents_moderation, |
|
|
PixverseStatus.failed, |
|
|
PixverseStatus.deleted, |
|
|
], |
|
|
status_extractor=lambda x: x.Resp.status, |
|
|
auth_kwargs=auth, |
|
|
node_id=cls.hidden.unique_id, |
|
|
result_url_extractor=get_video_url_from_response, |
|
|
estimated_duration=AVERAGE_DURATION_T2V, |
|
|
) |
|
|
response_poll = await operation.execute() |
|
|
|
|
|
async with aiohttp.ClientSession() as session: |
|
|
async with session.get(response_poll.Resp.url) as vid_response: |
|
|
return IO.NodeOutput(VideoFromFile(BytesIO(await vid_response.content.read()))) |
|
|
|
|
|
|
|
|
class PixVerseExtension(ComfyExtension): |
|
|
@override |
|
|
async def get_node_list(self) -> list[type[IO.ComfyNode]]: |
|
|
return [ |
|
|
PixverseTextToVideoNode, |
|
|
PixverseImageToVideoNode, |
|
|
PixverseTransitionVideoNode, |
|
|
PixverseTemplateNode, |
|
|
] |
|
|
|
|
|
|
|
|
async def comfy_entrypoint() -> PixVerseExtension: |
|
|
return PixVerseExtension() |
|
|
|