From c740f5035983dee074e26a19514e45b6247ee7ee Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 30 Apr 2025 17:10:14 -0500 Subject: [PATCH] Add Pixverse Transition Video node (#79) --- comfy_api_nodes/apis/pixverse_api.py | 14 -- comfy_api_nodes/nodes_pixverse.py | 188 ++++++++++++++++++++++++--- 2 files changed, 167 insertions(+), 35 deletions(-) diff --git a/comfy_api_nodes/apis/pixverse_api.py b/comfy_api_nodes/apis/pixverse_api.py index 9ce488e07..9bb29c383 100644 --- a/comfy_api_nodes/apis/pixverse_api.py +++ b/comfy_api_nodes/apis/pixverse_api.py @@ -89,20 +89,6 @@ class PixverseImageVideoRequest(BaseModel): water_mark: Optional[bool] = Field(None) -# class PixverseImageVideoRequest(BaseModel): -# quality: Optional[PixverseQuality] = Field(None) -# duration: Optional[PixverseDuration] = Field(None) -# img_id: int = Field(...) -# model: Optional[str] = Field("v3.5") -# motion_mode: Optional[PixverseMotionMode] = Field(PixverseMotionMode.normal) -# prompt: Optional[str ]= Field(None) -# negative_prompt: Optional[str] = Field(None) -# seed: Optional[int] = Field(None) -# style: Optional[str] = Field(None) -# template_id: Optional[int] = Field(None) -# water_mark: Optional[bool] = Field(None) - - class PixverseTransitionVideoRequest(BaseModel): quality: PixverseQuality = Field(...) duration: PixverseDuration = Field(...) diff --git a/comfy_api_nodes/nodes_pixverse.py b/comfy_api_nodes/nodes_pixverse.py index 5808575e4..8064b8f9f 100644 --- a/comfy_api_nodes/nodes_pixverse.py +++ b/comfy_api_nodes/nodes_pixverse.py @@ -3,6 +3,7 @@ from inspect import cleandoc from comfy_api_nodes.apis.pixverse_api import ( PixverseTextVideoRequest, PixverseImageVideoRequest, + PixverseTransitionVideoRequest, PixverseImageUploadResponse, PixverseVideoResponse, PixverseGenerationStatusResponse, @@ -32,6 +33,31 @@ import requests from io import BytesIO +def upload_image_to_pixverse(image: torch.Tensor, auth_token=None): + # first, upload image to Pixverse and get image id to use in actual generation call + files = { + "image": tensor_to_bytesio(image) + } + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/pixverse/image/upload", + method=HttpMethod.POST, + request_model=EmptyRequest, + response_model=PixverseImageUploadResponse, + ), + request=EmptyRequest(), + files=files, + content_type="multipart/form-data", + auth_token=auth_token, + ) + response_upload: PixverseImageUploadResponse = 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: """ Select template for Pixverse Video generation. @@ -266,26 +292,7 @@ class PixverseImageToVideoNode(ComfyNodeABC): auth_token=None, **kwargs, ): - # first, upload image to Pixverse and get image id to use in actual generation call - files = { - "image": tensor_to_bytesio(image) - } - operation = SynchronousOperation( - endpoint=ApiEndpoint( - path="/proxy/pixverse/image/upload", - method=HttpMethod.POST, - request_model=EmptyRequest, - response_model=PixverseImageUploadResponse, - ), - request=EmptyRequest(), - files=files, - content_type="multipart/form-data", - auth_token=auth_token, - ) - response_upload: PixverseImageUploadResponse = operation.execute() - - if response_upload.Resp is None: - raise Exception(f"Pixverse image upload request failed: '{response_upload.ErrMsg}'") + img_id = upload_image_to_pixverse(image, auth_token=auth_token) # 1080p is limited to 5 seconds duration # only normal motion_mode supported for 1080p or for non-5 second duration @@ -303,7 +310,144 @@ class PixverseImageToVideoNode(ComfyNodeABC): response_model=PixverseVideoResponse, ), request=PixverseImageVideoRequest( - img_id=response_upload.Resp.img_id, + 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_token=auth_token, + ) + response_api = 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_token=auth_token, + ) + response_poll = operation.execute() + + vid_response = requests.get(response_poll.Resp.url) + return (VideoFromFile(BytesIO(vid_response.content)),) + + +class PixverseTransitionVideoNode(ComfyNodeABC): + """ + Generates videos synchronously based on prompt and output_size. + """ + + RETURN_TYPES = (IO.VIDEO,) + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/video/Pixverse" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "first_frame": ( + IO.IMAGE, + ), + "last_frame": ( + IO.IMAGE, + ), + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the video generation", + }, + ), + "quality": ( + [resolution.value for resolution in PixverseQuality], + { + "default": PixverseQuality.res_540p, + }, + ), + "duration_seconds": ([dur.value for dur in PixverseDuration],), + "motion_mode": ([mode.value for mode in PixverseMotionMode],), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 2147483647, + "control_after_generate": True, + "tooltip": "Seed for video generation.", + }, + ), + }, + "optional": { + "negative_prompt": ( + IO.STRING, + { + "default": "", + "forceInput": True, + "tooltip": "An optional text description of undesired elements on an image.", + }, + ), + "pixverse_template": ( + PixverseIO.TEMPLATE, + { + "tooltip": "An optional template to influence style of generation, created by the Pixverse Template node." + } + ) + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + }, + } + + def api_call( + self, + first_frame: torch.Tensor, + last_frame: torch.Tensor, + prompt: str, + quality: str, + duration_seconds: int, + motion_mode: str, + seed, + negative_prompt: str=None, + pixverse_template: int=None, + auth_token=None, + **kwargs, + ): + first_frame_id = upload_image_to_pixverse(first_frame, auth_token=auth_token) + last_frame_id = upload_image_to_pixverse(last_frame, auth_token=auth_token) + + # 1080p is limited to 5 seconds duration + # only normal motion_mode supported for 1080p or for non-5 second duration + 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, @@ -340,11 +484,13 @@ class PixverseImageToVideoNode(ComfyNodeABC): NODE_CLASS_MAPPINGS = { "PixverseTextToVideoNode": PixverseTextToVideoNode, "PixverseImageToVideoNode": PixverseImageToVideoNode, + "PixverseTransitionVideoNode": PixverseTransitionVideoNode, "PixverseTemplateNode": PixverseTemplateNode, } NODE_DISPLAY_NAME_MAPPINGS = { "PixverseTextToVideoNode": "Pixverse Text to Video", "PixverseImageToVideoNode": "Pixverse Image to Video", + "PixverseTransitionVideoNode": "Pixverse Transition Video", "PixverseTemplateNode": "Pixverse Template", }