From 98ff01e1486353a3b0ddd8fa82fcbb25401f8364 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Tue, 13 May 2025 21:33:18 -0700 Subject: [PATCH] Display progress and result URL directly on API nodes (#8102) * [Luma] Print download URL of successful task result directly on nodes (#177) [Veo] Print download URL of successful task result directly on nodes (#184) [Recraft] Print download URL of successful task result directly on nodes (#183) [Pixverse] Print download URL of successful task result directly on nodes (#182) [Kling] Print download URL of successful task result directly on nodes (#181) [MiniMax] Print progress text and download URL of successful task result directly on nodes (#179) [Docs] Link to docs in `API_NODE` class property type annotation comment (#178) [Ideogram] Print download URL of successful task result directly on nodes (#176) [Kling] Print download URL of successful task result directly on nodes (#181) [Veo] Print download URL of successful task result directly on nodes (#184) [Recraft] Print download URL of successful task result directly on nodes (#183) [Pixverse] Print download URL of successful task result directly on nodes (#182) [MiniMax] Print progress text and download URL of successful task result directly on nodes (#179) [Docs] Link to docs in `API_NODE` class property type annotation comment (#178) [Luma] Print download URL of successful task result directly on nodes (#177) [Ideogram] Print download URL of successful task result directly on nodes (#176) Show output URL and progress text on Pika nodes (#168) [BFL] Print download URL of successful task result directly on nodes (#175) [OpenAI ] Print download URL of successful task result directly on nodes (#174) * fix ruff errors * fix 3.10 syntax error --- comfy/comfy_types/node_typing.py | 2 +- comfy_api_nodes/apinode_utils.py | 11 +- comfy_api_nodes/apis/client.py | 42 +++++++- comfy_api_nodes/nodes_bfl.py | 63 ++++++++---- comfy_api_nodes/nodes_ideogram.py | 24 ++++- comfy_api_nodes/nodes_kling.py | 164 ++++++++++++++++++++++++++---- comfy_api_nodes/nodes_luma.py | 33 ++++++ comfy_api_nodes/nodes_minimax.py | 27 ++++- comfy_api_nodes/nodes_openai.py | 12 ++- comfy_api_nodes/nodes_pika.py | 42 ++++++-- comfy_api_nodes/nodes_pixverse.py | 99 ++++++++++++------ comfy_api_nodes/nodes_recraft.py | 21 ++++ comfy_api_nodes/nodes_veo2.py | 26 ++++- 13 files changed, 474 insertions(+), 92 deletions(-) diff --git a/comfy/comfy_types/node_typing.py b/comfy/comfy_types/node_typing.py index 2ffc9c02..470eb9fd 100644 --- a/comfy/comfy_types/node_typing.py +++ b/comfy/comfy_types/node_typing.py @@ -235,7 +235,7 @@ class ComfyNodeABC(ABC): DEPRECATED: bool """Flags a node as deprecated, indicating to users that they should find alternatives to this node.""" API_NODE: Optional[bool] - """Flags a node as an API node.""" + """Flags a node as an API node. See: https://docs.comfy.org/tutorials/api-nodes/overview.""" @classmethod @abstractmethod diff --git a/comfy_api_nodes/apinode_utils.py b/comfy_api_nodes/apinode_utils.py index e28d7d60..87d8c3e1 100644 --- a/comfy_api_nodes/apinode_utils.py +++ b/comfy_api_nodes/apinode_utils.py @@ -1,7 +1,7 @@ from __future__ import annotations import io import logging -from typing import Optional +from typing import Optional, Union from comfy.utils import common_upscale from comfy_api.input_impl import VideoFromFile from comfy_api.util import VideoContainer, VideoCodec @@ -15,6 +15,7 @@ from comfy_api_nodes.apis.client import ( UploadRequest, UploadResponse, ) +from server import PromptServer import numpy as np @@ -60,7 +61,9 @@ def downscale_image_tensor(image, total_pixels=1536 * 1024) -> torch.Tensor: return s -def validate_and_cast_response(response, timeout: int = None) -> torch.Tensor: +def validate_and_cast_response( + response, timeout: int = None, node_id: Union[str, None] = None +) -> torch.Tensor: """Validates and casts a response to a torch.Tensor. Args: @@ -94,6 +97,10 @@ def validate_and_cast_response(response, timeout: int = None) -> torch.Tensor: img = Image.open(io.BytesIO(img_data)) elif image_url: + if node_id: + PromptServer.instance.send_progress_text( + f"Result URL: {image_url}", node_id + ) img_response = requests.get(image_url, timeout=timeout) if img_response.status_code != 200: raise ValueError("Failed to download the image") diff --git a/comfy_api_nodes/apis/client.py b/comfy_api_nodes/apis/client.py index 158d20de..838ff1e8 100644 --- a/comfy_api_nodes/apis/client.py +++ b/comfy_api_nodes/apis/client.py @@ -103,6 +103,7 @@ from urllib.parse import urljoin, urlparse from pydantic import BaseModel, Field import uuid # For generating unique operation IDs +from server import PromptServer from comfy.cli_args import args from comfy import utils from . import request_logger @@ -900,6 +901,7 @@ class PollingOperation(Generic[T, R]): failed_statuses: list, status_extractor: Callable[[R], str], progress_extractor: Callable[[R], float] = None, + result_url_extractor: Callable[[R], str] = None, request: Optional[T] = None, api_base: str | None = None, auth_token: Optional[str] = None, @@ -910,6 +912,8 @@ class PollingOperation(Generic[T, R]): max_retries: int = 3, # Max retries per individual API call retry_delay: float = 1.0, retry_backoff_factor: float = 2.0, + estimated_duration: Optional[float] = None, + node_id: Optional[str] = None, ): self.poll_endpoint = poll_endpoint self.request = request @@ -924,12 +928,15 @@ class PollingOperation(Generic[T, R]): self.max_retries = max_retries self.retry_delay = retry_delay self.retry_backoff_factor = retry_backoff_factor + self.estimated_duration = estimated_duration # Polling configuration self.status_extractor = status_extractor or ( lambda x: getattr(x, "status", None) ) self.progress_extractor = progress_extractor + self.result_url_extractor = result_url_extractor + self.node_id = node_id self.completed_statuses = completed_statuses self.failed_statuses = failed_statuses @@ -965,6 +972,26 @@ class PollingOperation(Generic[T, R]): except Exception as e: raise Exception(f"Error during polling: {str(e)}") + def _display_text_on_node(self, text: str): + """Sends text to the client which will be displayed on the node in the UI""" + if not self.node_id: + return + + PromptServer.instance.send_progress_text(text, self.node_id) + + def _display_time_progress_on_node(self, time_completed: int): + if not self.node_id: + return + + if self.estimated_duration is not None: + estimated_time_remaining = max( + 0, int(self.estimated_duration) - int(time_completed) + ) + message = f"Task in progress: {time_completed:.0f}s (~{estimated_time_remaining:.0f}s remaining)" + else: + message = f"Task in progress: {time_completed:.0f}s" + self._display_text_on_node(message) + def _check_task_status(self, response: R) -> TaskStatus: """Check task status using the status extractor function""" try: @@ -1031,7 +1058,15 @@ class PollingOperation(Generic[T, R]): progress.update_absolute(new_progress, total=PROGRESS_BAR_MAX) if status == TaskStatus.COMPLETED: - logging.debug("[DEBUG] Task completed successfully") + message = "Task completed successfully" + if self.result_url_extractor: + result_url = self.result_url_extractor(response_obj) + if result_url: + message = f"Result URL: {result_url}" + else: + message = "Task completed successfully!" + logging.debug(f"[DEBUG] {message}") + self._display_text_on_node(message) self.final_response = response_obj if self.progress_extractor: progress.update(100) @@ -1047,7 +1082,10 @@ class PollingOperation(Generic[T, R]): logging.debug( f"[DEBUG] Waiting {self.poll_interval} seconds before next poll" ) - time.sleep(self.poll_interval) + for i in range(int(self.poll_interval)): + time_completed = (poll_count * self.poll_interval) + i + self._display_time_progress_on_node(time_completed) + time.sleep(1) except (LocalNetworkError, ApiServerError) as e: # For network-related errors, increment error count and potentially abort diff --git a/comfy_api_nodes/nodes_bfl.py b/comfy_api_nodes/nodes_bfl.py index 66ef1b39..509170b3 100644 --- a/comfy_api_nodes/nodes_bfl.py +++ b/comfy_api_nodes/nodes_bfl.py @@ -1,5 +1,6 @@ import io from inspect import cleandoc +from typing import Union from comfy.comfy_types.node_typing import IO, ComfyNodeABC from comfy_api_nodes.apis.bfl_api import ( BFLStatus, @@ -30,6 +31,7 @@ import requests import torch import base64 import time +from server import PromptServer def convert_mask_to_image(mask: torch.Tensor): @@ -42,14 +44,19 @@ def convert_mask_to_image(mask: torch.Tensor): def handle_bfl_synchronous_operation( - operation: SynchronousOperation, timeout_bfl_calls=360 + operation: SynchronousOperation, + timeout_bfl_calls=360, + node_id: Union[str, None] = None, ): response_api: BFLFluxProGenerateResponse = operation.execute() return _poll_until_generated( - response_api.polling_url, timeout=timeout_bfl_calls + response_api.polling_url, timeout=timeout_bfl_calls, node_id=node_id ) -def _poll_until_generated(polling_url: str, timeout=360): + +def _poll_until_generated( + polling_url: str, timeout=360, node_id: Union[str, None] = None +): # used bfl-comfy-nodes to verify code implementation: # https://github.com/black-forest-labs/bfl-comfy-nodes/tree/main start_time = time.time() @@ -61,11 +68,21 @@ def _poll_until_generated(polling_url: str, timeout=360): request = requests.Request(method=HttpMethod.GET, url=polling_url) # NOTE: should True loop be replaced with checking if workflow has been interrupted? while True: + if node_id: + time_elapsed = time.time() - start_time + PromptServer.instance.send_progress_text( + f"Generating ({time_elapsed:.0f}s)", node_id + ) + response = requests.Session().send(request.prepare()) if response.status_code == 200: result = response.json() if result["status"] == BFLStatus.ready: img_url = result["result"]["sample"] + if node_id: + PromptServer.instance.send_progress_text( + f"Result URL: {img_url}", node_id + ) img_response = requests.get(img_url) return process_image_response(img_response) elif result["status"] in [ @@ -180,6 +197,7 @@ class FluxProUltraImageNode(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -212,6 +230,7 @@ class FluxProUltraImageNode(ComfyNodeABC): seed=0, image_prompt=None, image_prompt_strength=0.1, + unique_id: Union[str, None] = None, **kwargs, ): if image_prompt is None: @@ -246,7 +265,7 @@ class FluxProUltraImageNode(ComfyNodeABC): ), auth_kwargs=kwargs, ) - output_image = handle_bfl_synchronous_operation(operation) + output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id) return (output_image,) @@ -320,6 +339,7 @@ class FluxProImageNode(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -338,6 +358,7 @@ class FluxProImageNode(ComfyNodeABC): seed=0, image_prompt=None, # image_prompt_strength=0.1, + unique_id: Union[str, None] = None, **kwargs, ): image_prompt = ( @@ -363,7 +384,7 @@ class FluxProImageNode(ComfyNodeABC): ), auth_kwargs=kwargs, ) - output_image = handle_bfl_synchronous_operation(operation) + output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id) return (output_image,) @@ -457,11 +478,11 @@ class FluxProExpandNode(ComfyNodeABC): }, ), }, - "optional": { - }, + "optional": {}, "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -483,6 +504,7 @@ class FluxProExpandNode(ComfyNodeABC): steps: int, guidance: float, seed=0, + unique_id: Union[str, None] = None, **kwargs, ): image = convert_image_to_base64(image) @@ -508,7 +530,7 @@ class FluxProExpandNode(ComfyNodeABC): ), auth_kwargs=kwargs, ) - output_image = handle_bfl_synchronous_operation(operation) + output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id) return (output_image,) @@ -568,11 +590,11 @@ class FluxProFillNode(ComfyNodeABC): }, ), }, - "optional": { - }, + "optional": {}, "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -591,13 +613,14 @@ class FluxProFillNode(ComfyNodeABC): steps: int, guidance: float, seed=0, + unique_id: Union[str, None] = None, **kwargs, ): # prepare mask mask = resize_mask_to_image(mask, image) mask = convert_image_to_base64(convert_mask_to_image(mask)) # make sure image will have alpha channel removed - image = convert_image_to_base64(image[:,:,:,:3]) + image = convert_image_to_base64(image[:, :, :, :3]) operation = SynchronousOperation( endpoint=ApiEndpoint( @@ -617,7 +640,7 @@ class FluxProFillNode(ComfyNodeABC): ), auth_kwargs=kwargs, ) - output_image = handle_bfl_synchronous_operation(operation) + output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id) return (output_image,) @@ -702,11 +725,11 @@ class FluxProCannyNode(ComfyNodeABC): }, ), }, - "optional": { - }, + "optional": {}, "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -727,9 +750,10 @@ class FluxProCannyNode(ComfyNodeABC): steps: int, guidance: float, seed=0, + unique_id: Union[str, None] = None, **kwargs, ): - control_image = convert_image_to_base64(control_image[:,:,:,:3]) + control_image = convert_image_to_base64(control_image[:, :, :, :3]) preprocessed_image = None # scale canny threshold between 0-500, to match BFL's API @@ -765,7 +789,7 @@ class FluxProCannyNode(ComfyNodeABC): ), auth_kwargs=kwargs, ) - output_image = handle_bfl_synchronous_operation(operation) + output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id) return (output_image,) @@ -830,11 +854,11 @@ class FluxProDepthNode(ComfyNodeABC): }, ), }, - "optional": { - }, + "optional": {}, "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -853,6 +877,7 @@ class FluxProDepthNode(ComfyNodeABC): steps: int, guidance: float, seed=0, + unique_id: Union[str, None] = None, **kwargs, ): control_image = convert_image_to_base64(control_image[:,:,:,:3]) @@ -880,7 +905,7 @@ class FluxProDepthNode(ComfyNodeABC): ), auth_kwargs=kwargs, ) - output_image = handle_bfl_synchronous_operation(operation) + output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id) return (output_image,) diff --git a/comfy_api_nodes/nodes_ideogram.py b/comfy_api_nodes/nodes_ideogram.py index d25468b1..b1cbf511 100644 --- a/comfy_api_nodes/nodes_ideogram.py +++ b/comfy_api_nodes/nodes_ideogram.py @@ -23,6 +23,7 @@ from comfy_api_nodes.apinode_utils import ( bytesio_to_image_tensor, resize_mask_to_image, ) +from server import PromptServer V1_V1_RES_MAP = { "Auto":"AUTO", @@ -232,6 +233,19 @@ def download_and_process_images(image_urls): return stacked_tensors +def display_image_urls_on_node(image_urls, node_id): + if node_id and image_urls: + if len(image_urls) == 1: + PromptServer.instance.send_progress_text( + f"Generated Image URL:\n{image_urls[0]}", node_id + ) + else: + urls_text = "Generated Image URLs:\n" + "\n".join( + f"{i+1}. {url}" for i, url in enumerate(image_urls) + ) + PromptServer.instance.send_progress_text(urls_text, node_id) + + class IdeogramV1(ComfyNodeABC): """ Generates images using the Ideogram V1 model. @@ -304,6 +318,7 @@ class IdeogramV1(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -322,6 +337,7 @@ class IdeogramV1(ComfyNodeABC): seed=0, negative_prompt="", num_images=1, + unique_id=None, **kwargs, ): # Determine the model based on turbo setting @@ -361,6 +377,7 @@ class IdeogramV1(ComfyNodeABC): if not image_urls: raise Exception("No image URLs were generated in the response") + display_image_urls_on_node(image_urls, unique_id) return (download_and_process_images(image_urls),) @@ -460,6 +477,7 @@ class IdeogramV2(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -481,6 +499,7 @@ class IdeogramV2(ComfyNodeABC): negative_prompt="", num_images=1, color_palette="", + unique_id=None, **kwargs, ): aspect_ratio = V1_V2_RATIO_MAP.get(aspect_ratio, None) @@ -534,6 +553,7 @@ class IdeogramV2(ComfyNodeABC): if not image_urls: raise Exception("No image URLs were generated in the response") + display_image_urls_on_node(image_urls, unique_id) return (download_and_process_images(image_urls),) class IdeogramV3(ComfyNodeABC): @@ -623,6 +643,7 @@ class IdeogramV3(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -643,6 +664,7 @@ class IdeogramV3(ComfyNodeABC): seed=0, num_images=1, rendering_speed="BALANCED", + unique_id=None, **kwargs, ): # Check if both image and mask are provided for editing mode @@ -762,6 +784,7 @@ class IdeogramV3(ComfyNodeABC): if not image_urls: raise Exception("No image URLs were generated in the response") + display_image_urls_on_node(image_urls, unique_id) return (download_and_process_images(image_urls),) @@ -776,4 +799,3 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IdeogramV2": "Ideogram V2", "IdeogramV3": "Ideogram V3", } - diff --git a/comfy_api_nodes/nodes_kling.py b/comfy_api_nodes/nodes_kling.py index 2d0fd888..456a8690 100644 --- a/comfy_api_nodes/nodes_kling.py +++ b/comfy_api_nodes/nodes_kling.py @@ -6,6 +6,7 @@ For source of truth on the allowed permutations of request fields, please refere from __future__ import annotations from typing import Optional, TypeVar, Any +from collections.abc import Callable import math import logging @@ -86,6 +87,15 @@ MAX_PROMPT_LENGTH_IMAGE_GEN = 500 MAX_NEGATIVE_PROMPT_LENGTH_IMAGE_GEN = 200 MAX_PROMPT_LENGTH_LIP_SYNC = 120 +# TODO: adjust based on tests +AVERAGE_DURATION_T2V = 319 # 319, +AVERAGE_DURATION_I2V = 164 # 164, +AVERAGE_DURATION_LIP_SYNC = 120 +AVERAGE_DURATION_VIRTUAL_TRY_ON = 19 # 19, +AVERAGE_DURATION_IMAGE_GEN = 32 +AVERAGE_DURATION_VIDEO_EFFECTS = 320 +AVERAGE_DURATION_VIDEO_EXTEND = 320 + R = TypeVar("R") @@ -95,7 +105,13 @@ class KlingApiError(Exception): pass -def poll_until_finished(auth_kwargs: dict[str,str], api_endpoint: ApiEndpoint[Any, R]) -> R: +def poll_until_finished( + auth_kwargs: dict[str, str], + api_endpoint: ApiEndpoint[Any, R], + result_url_extractor: Optional[Callable[[R], str]] = None, + estimated_duration: Optional[int] = None, + node_id: Optional[str] = None, +) -> R: """Polls the Kling API endpoint until the task reaches a terminal state, then returns the response.""" return PollingOperation( poll_endpoint=api_endpoint, @@ -109,6 +125,9 @@ def poll_until_finished(auth_kwargs: dict[str,str], api_endpoint: ApiEndpoint[An else None ), auth_kwargs=auth_kwargs, + result_url_extractor=result_url_extractor, + estimated_duration=estimated_duration, + node_id=node_id, ).execute() @@ -227,7 +246,9 @@ def get_camera_control_input_config( def get_video_from_response(response) -> KlingVideoResult: - """Returns the first video object from the Kling video generation task result.""" + """Returns the first video object from the Kling video generation task result. + Will raise an error if the response is not valid. + """ video = response.data.task_result.videos[0] logging.info( "Kling task %s succeeded. Video URL: %s", response.data.task_id, video.url @@ -235,12 +256,37 @@ def get_video_from_response(response) -> KlingVideoResult: return video +def get_video_url_from_response(response) -> Optional[str]: + """Returns the first video url from the Kling video generation task result. + Will not raise an error if the response is not valid. + """ + if response and is_valid_video_response(response): + return str(get_video_from_response(response).url) + else: + return None + + def get_images_from_response(response) -> list[KlingImageResult]: + """Returns the list of image objects from the Kling image generation task result. + Will raise an error if the response is not valid. + """ images = response.data.task_result.images logging.info("Kling task %s succeeded. Images: %s", response.data.task_id, images) return images +def get_images_urls_from_response(response) -> Optional[str]: + """Returns the list of image urls from the Kling image generation task result. + Will not raise an error if the response is not valid. If there is only one image, returns the url as a string. If there are multiple images, returns a list of urls. + """ + if response and is_valid_image_response(response): + images = get_images_from_response(response) + image_urls = [str(image.url) for image in images] + return "\n".join(image_urls) + else: + return None + + def video_result_to_node_output( video: KlingVideoResult, ) -> tuple[VideoFromFile, str, str]: @@ -312,6 +358,7 @@ class KlingCameraControls(KlingNodeBase): RETURN_TYPES = ("CAMERA_CONTROL",) RETURN_NAMES = ("camera_control",) FUNCTION = "main" + API_NODE = False # This is just a helper node, it doesn't make an API call @classmethod def VALIDATE_INPUTS( @@ -421,6 +468,7 @@ class KlingTextToVideoNode(KlingNodeBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -428,7 +476,9 @@ class KlingTextToVideoNode(KlingNodeBase): RETURN_NAMES = ("VIDEO", "video_id", "duration") DESCRIPTION = "Kling Text to Video Node" - def get_response(self, task_id: str, auth_kwargs: dict[str,str]) -> KlingText2VideoResponse: + def get_response( + self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None + ) -> KlingText2VideoResponse: return poll_until_finished( auth_kwargs, ApiEndpoint( @@ -437,6 +487,9 @@ class KlingTextToVideoNode(KlingNodeBase): request_model=EmptyRequest, response_model=KlingText2VideoResponse, ), + result_url_extractor=get_video_url_from_response, + estimated_duration=AVERAGE_DURATION_T2V, + node_id=node_id, ) def api_call( @@ -449,6 +502,7 @@ class KlingTextToVideoNode(KlingNodeBase): camera_control: Optional[KlingCameraControl] = None, model_name: Optional[str] = None, duration: Optional[str] = None, + unique_id: Optional[str] = None, **kwargs, ) -> tuple[VideoFromFile, str, str]: validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V) @@ -478,7 +532,9 @@ class KlingTextToVideoNode(KlingNodeBase): validate_task_creation_response(task_creation_response) task_id = task_creation_response.data.task_id - final_response = self.get_response(task_id, auth_kwargs=kwargs) + final_response = self.get_response( + task_id, auth_kwargs=kwargs, node_id=unique_id + ) validate_video_result_response(final_response) video = get_video_from_response(final_response) @@ -528,6 +584,7 @@ class KlingCameraControlT2VNode(KlingTextToVideoNode): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -540,6 +597,7 @@ class KlingCameraControlT2VNode(KlingTextToVideoNode): cfg_scale: float, aspect_ratio: str, camera_control: Optional[KlingCameraControl] = None, + unique_id: Optional[str] = None, **kwargs, ): return super().api_call( @@ -613,6 +671,7 @@ class KlingImage2VideoNode(KlingNodeBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -620,7 +679,9 @@ class KlingImage2VideoNode(KlingNodeBase): RETURN_NAMES = ("VIDEO", "video_id", "duration") DESCRIPTION = "Kling Image to Video Node" - def get_response(self, task_id: str, auth_kwargs: dict[str,str]) -> KlingImage2VideoResponse: + def get_response( + self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None + ) -> KlingImage2VideoResponse: return poll_until_finished( auth_kwargs, ApiEndpoint( @@ -629,6 +690,9 @@ class KlingImage2VideoNode(KlingNodeBase): request_model=KlingImage2VideoRequest, response_model=KlingImage2VideoResponse, ), + result_url_extractor=get_video_url_from_response, + estimated_duration=AVERAGE_DURATION_I2V, + node_id=node_id, ) def api_call( @@ -643,6 +707,7 @@ class KlingImage2VideoNode(KlingNodeBase): duration: str, camera_control: Optional[KlingCameraControl] = None, end_frame: Optional[torch.Tensor] = None, + unique_id: Optional[str] = None, **kwargs, ) -> tuple[VideoFromFile]: validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_I2V) @@ -681,7 +746,9 @@ class KlingImage2VideoNode(KlingNodeBase): validate_task_creation_response(task_creation_response) task_id = task_creation_response.data.task_id - final_response = self.get_response(task_id, auth_kwargs=kwargs) + final_response = self.get_response( + task_id, auth_kwargs=kwargs, node_id=unique_id + ) validate_video_result_response(final_response) video = get_video_from_response(final_response) @@ -734,6 +801,7 @@ class KlingCameraControlI2VNode(KlingImage2VideoNode): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -747,6 +815,7 @@ class KlingCameraControlI2VNode(KlingImage2VideoNode): cfg_scale: float, aspect_ratio: str, camera_control: KlingCameraControl, + unique_id: Optional[str] = None, **kwargs, ): return super().api_call( @@ -759,6 +828,7 @@ class KlingCameraControlI2VNode(KlingImage2VideoNode): prompt=prompt, negative_prompt=negative_prompt, camera_control=camera_control, + unique_id=unique_id, **kwargs, ) @@ -830,6 +900,7 @@ class KlingStartEndFrameNode(KlingImage2VideoNode): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -844,6 +915,7 @@ class KlingStartEndFrameNode(KlingImage2VideoNode): cfg_scale: float, aspect_ratio: str, mode: str, + unique_id: Optional[str] = None, **kwargs, ): mode, duration, model_name = KlingStartEndFrameNode.get_mode_string_mapping()[ @@ -859,6 +931,7 @@ class KlingStartEndFrameNode(KlingImage2VideoNode): aspect_ratio=aspect_ratio, duration=duration, end_frame=end_frame, + unique_id=unique_id, **kwargs, ) @@ -892,6 +965,7 @@ class KlingVideoExtendNode(KlingNodeBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -899,7 +973,9 @@ class KlingVideoExtendNode(KlingNodeBase): RETURN_NAMES = ("VIDEO", "video_id", "duration") DESCRIPTION = "Kling Video Extend Node. Extend videos made by other Kling nodes. The video_id is created by using other Kling Nodes." - def get_response(self, task_id: str, auth_kwargs: dict[str,str]) -> KlingVideoExtendResponse: + def get_response( + self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None + ) -> KlingVideoExtendResponse: return poll_until_finished( auth_kwargs, ApiEndpoint( @@ -908,6 +984,9 @@ class KlingVideoExtendNode(KlingNodeBase): request_model=EmptyRequest, response_model=KlingVideoExtendResponse, ), + result_url_extractor=get_video_url_from_response, + estimated_duration=AVERAGE_DURATION_VIDEO_EXTEND, + node_id=node_id, ) def api_call( @@ -916,6 +995,7 @@ class KlingVideoExtendNode(KlingNodeBase): negative_prompt: str, cfg_scale: float, video_id: str, + unique_id: Optional[str] = None, **kwargs, ) -> tuple[VideoFromFile, str, str]: validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V) @@ -939,7 +1019,9 @@ class KlingVideoExtendNode(KlingNodeBase): validate_task_creation_response(task_creation_response) task_id = task_creation_response.data.task_id - final_response = self.get_response(task_id, auth_kwargs=kwargs) + final_response = self.get_response( + task_id, auth_kwargs=kwargs, node_id=unique_id + ) validate_video_result_response(final_response) video = get_video_from_response(final_response) @@ -952,7 +1034,9 @@ class KlingVideoEffectsBase(KlingNodeBase): RETURN_TYPES = ("VIDEO", "STRING", "STRING") RETURN_NAMES = ("VIDEO", "video_id", "duration") - def get_response(self, task_id: str, auth_kwargs: dict[str,str]) -> KlingVideoEffectsResponse: + def get_response( + self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None + ) -> KlingVideoEffectsResponse: return poll_until_finished( auth_kwargs, ApiEndpoint( @@ -961,6 +1045,9 @@ class KlingVideoEffectsBase(KlingNodeBase): request_model=EmptyRequest, response_model=KlingVideoEffectsResponse, ), + result_url_extractor=get_video_url_from_response, + estimated_duration=AVERAGE_DURATION_VIDEO_EFFECTS, + node_id=node_id, ) def api_call( @@ -972,6 +1059,7 @@ class KlingVideoEffectsBase(KlingNodeBase): image_1: torch.Tensor, image_2: Optional[torch.Tensor] = None, mode: Optional[KlingVideoGenMode] = None, + unique_id: Optional[str] = None, **kwargs, ): if dual_character: @@ -1009,7 +1097,9 @@ class KlingVideoEffectsBase(KlingNodeBase): validate_task_creation_response(task_creation_response) task_id = task_creation_response.data.task_id - final_response = self.get_response(task_id, auth_kwargs=kwargs) + final_response = self.get_response( + task_id, auth_kwargs=kwargs, node_id=unique_id + ) validate_video_result_response(final_response) video = get_video_from_response(final_response) @@ -1053,6 +1143,7 @@ class KlingDualCharacterVideoEffectNode(KlingVideoEffectsBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -1068,6 +1159,7 @@ class KlingDualCharacterVideoEffectNode(KlingVideoEffectsBase): model_name: KlingCharacterEffectModelName, mode: KlingVideoGenMode, duration: KlingVideoGenDuration, + unique_id: Optional[str] = None, **kwargs, ): video, _, duration = super().api_call( @@ -1078,10 +1170,12 @@ class KlingDualCharacterVideoEffectNode(KlingVideoEffectsBase): duration=duration, image_1=image_left, image_2=image_right, + unique_id=unique_id, **kwargs, ) return video, duration + class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase): """Kling Single Image Video Effect Node""" @@ -1117,6 +1211,7 @@ class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -1128,6 +1223,7 @@ class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase): effect_scene: KlingSingleImageEffectsScene, model_name: KlingSingleImageEffectModelName, duration: KlingVideoGenDuration, + unique_id: Optional[str] = None, **kwargs, ): return super().api_call( @@ -1136,6 +1232,7 @@ class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase): model_name=model_name, duration=duration, image_1=image, + unique_id=unique_id, **kwargs, ) @@ -1154,7 +1251,9 @@ class KlingLipSyncBase(KlingNodeBase): f"Text is too long. Maximum length is {MAX_PROMPT_LENGTH_LIP_SYNC} characters." ) - def get_response(self, task_id: str, auth_kwargs: dict[str,str]) -> KlingLipSyncResponse: + def get_response( + self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None + ) -> KlingLipSyncResponse: """Polls the Kling API endpoint until the task reaches a terminal state.""" return poll_until_finished( auth_kwargs, @@ -1164,6 +1263,9 @@ class KlingLipSyncBase(KlingNodeBase): request_model=EmptyRequest, response_model=KlingLipSyncResponse, ), + result_url_extractor=get_video_url_from_response, + estimated_duration=AVERAGE_DURATION_LIP_SYNC, + node_id=node_id, ) def api_call( @@ -1175,7 +1277,8 @@ class KlingLipSyncBase(KlingNodeBase): text: Optional[str] = None, voice_speed: Optional[float] = None, voice_id: Optional[str] = None, - **kwargs + unique_id: Optional[str] = None, + **kwargs, ) -> tuple[VideoFromFile, str, str]: if text: self.validate_text(text) @@ -1217,7 +1320,9 @@ class KlingLipSyncBase(KlingNodeBase): validate_task_creation_response(task_creation_response) task_id = task_creation_response.data.task_id - final_response = self.get_response(task_id, auth_kwargs=kwargs) + final_response = self.get_response( + task_id, auth_kwargs=kwargs, node_id=unique_id + ) validate_video_result_response(final_response) video = get_video_from_response(final_response) @@ -1243,6 +1348,7 @@ class KlingLipSyncAudioToVideoNode(KlingLipSyncBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -1253,6 +1359,7 @@ class KlingLipSyncAudioToVideoNode(KlingLipSyncBase): video: VideoInput, audio: AudioInput, voice_language: str, + unique_id: Optional[str] = None, **kwargs, ): return super().api_call( @@ -1260,6 +1367,7 @@ class KlingLipSyncAudioToVideoNode(KlingLipSyncBase): audio=audio, voice_language=voice_language, mode="audio2video", + unique_id=unique_id, **kwargs, ) @@ -1352,6 +1460,7 @@ class KlingLipSyncTextToVideoNode(KlingLipSyncBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -1363,6 +1472,7 @@ class KlingLipSyncTextToVideoNode(KlingLipSyncBase): text: str, voice: str, voice_speed: float, + unique_id: Optional[str] = None, **kwargs, ): voice_id, voice_language = KlingLipSyncTextToVideoNode.get_voice_config()[voice] @@ -1373,6 +1483,7 @@ class KlingLipSyncTextToVideoNode(KlingLipSyncBase): voice_id=voice_id, voice_speed=voice_speed, mode="text2video", + unique_id=unique_id, **kwargs, ) @@ -1413,13 +1524,14 @@ class KlingVirtualTryOnNode(KlingImageGenerationBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } - DESCRIPTION = "Kling Virtual Try On Node. Input a human image and a cloth image to try on the cloth on the human." + DESCRIPTION = "Kling Virtual Try On Node. Input a human image and a cloth image to try on the cloth on the human. You can merge multiple clothing item pictures into one image with a white background." def get_response( - self, task_id: str, auth_kwargs: dict[str,str] = None + self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None ) -> KlingVirtualTryOnResponse: return poll_until_finished( auth_kwargs, @@ -1429,6 +1541,9 @@ class KlingVirtualTryOnNode(KlingImageGenerationBase): request_model=EmptyRequest, response_model=KlingVirtualTryOnResponse, ), + result_url_extractor=get_images_urls_from_response, + estimated_duration=AVERAGE_DURATION_VIRTUAL_TRY_ON, + node_id=node_id, ) def api_call( @@ -1436,6 +1551,7 @@ class KlingVirtualTryOnNode(KlingImageGenerationBase): human_image: torch.Tensor, cloth_image: torch.Tensor, model_name: KlingVirtualTryOnModelName, + unique_id: Optional[str] = None, **kwargs, ): initial_operation = SynchronousOperation( @@ -1457,7 +1573,9 @@ class KlingVirtualTryOnNode(KlingImageGenerationBase): validate_task_creation_response(task_creation_response) task_id = task_creation_response.data.task_id - final_response = self.get_response(task_id, auth_kwargs=kwargs) + final_response = self.get_response( + task_id, auth_kwargs=kwargs, node_id=unique_id + ) validate_image_result_response(final_response) images = get_images_from_response(final_response) @@ -1528,13 +1646,17 @@ class KlingImageGenerationNode(KlingImageGenerationBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } DESCRIPTION = "Kling Image Generation Node. Generate an image from a text prompt with an optional reference image." def get_response( - self, task_id: str, auth_kwargs: Optional[dict[str,str]] = None + self, + task_id: str, + auth_kwargs: Optional[dict[str, str]], + node_id: Optional[str] = None, ) -> KlingImageGenerationsResponse: return poll_until_finished( auth_kwargs, @@ -1544,6 +1666,9 @@ class KlingImageGenerationNode(KlingImageGenerationBase): request_model=EmptyRequest, response_model=KlingImageGenerationsResponse, ), + result_url_extractor=get_images_urls_from_response, + estimated_duration=AVERAGE_DURATION_IMAGE_GEN, + node_id=node_id, ) def api_call( @@ -1557,6 +1682,7 @@ class KlingImageGenerationNode(KlingImageGenerationBase): n: int, aspect_ratio: KlingImageGenAspectRatio, image: Optional[torch.Tensor] = None, + unique_id: Optional[str] = None, **kwargs, ): self.validate_prompt(prompt, negative_prompt) @@ -1589,7 +1715,9 @@ class KlingImageGenerationNode(KlingImageGenerationBase): validate_task_creation_response(task_creation_response) task_id = task_creation_response.data.task_id - final_response = self.get_response(task_id, auth_kwargs=kwargs) + final_response = self.get_response( + task_id, auth_kwargs=kwargs, node_id=unique_id + ) validate_image_result_response(final_response) images = get_images_from_response(final_response) diff --git a/comfy_api_nodes/nodes_luma.py b/comfy_api_nodes/nodes_luma.py index bd33a53e..525dc38e 100644 --- a/comfy_api_nodes/nodes_luma.py +++ b/comfy_api_nodes/nodes_luma.py @@ -36,11 +36,20 @@ from comfy_api_nodes.apinode_utils import ( process_image_response, validate_string, ) +from server import PromptServer import requests import torch from io import BytesIO +LUMA_T2V_AVERAGE_DURATION = 105 +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(ComfyNodeABC): """ @@ -204,6 +213,7 @@ class LumaImageGenerationNode(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -217,6 +227,7 @@ class LumaImageGenerationNode(ComfyNodeABC): image_luma_ref: LumaReferenceChain = None, style_image: torch.Tensor = None, character_image: torch.Tensor = None, + unique_id: str = None, **kwargs, ): validate_string(prompt, strip_whitespace=True, min_length=3) @@ -271,6 +282,8 @@ class LumaImageGenerationNode(ComfyNodeABC): completed_statuses=[LumaState.completed], failed_statuses=[LumaState.failed], status_extractor=lambda x: x.state, + result_url_extractor=image_result_url_extractor, + node_id=unique_id, auth_kwargs=kwargs, ) response_poll = operation.execute() @@ -353,6 +366,7 @@ class LumaImageModifyNode(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -363,6 +377,7 @@ class LumaImageModifyNode(ComfyNodeABC): image: torch.Tensor, image_weight: float, seed, + unique_id: str = None, **kwargs, ): # first, upload image @@ -399,6 +414,8 @@ class LumaImageModifyNode(ComfyNodeABC): completed_statuses=[LumaState.completed], failed_statuses=[LumaState.failed], status_extractor=lambda x: x.state, + result_url_extractor=image_result_url_extractor, + node_id=unique_id, auth_kwargs=kwargs, ) response_poll = operation.execute() @@ -473,6 +490,7 @@ class LumaTextToVideoGenerationNode(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -486,6 +504,7 @@ class LumaTextToVideoGenerationNode(ComfyNodeABC): loop: bool, seed, luma_concepts: LumaConceptChain = None, + unique_id: str = None, **kwargs, ): validate_string(prompt, strip_whitespace=False, min_length=3) @@ -512,6 +531,9 @@ class LumaTextToVideoGenerationNode(ComfyNodeABC): ) response_api: LumaGeneration = operation.execute() + if unique_id: + PromptServer.instance.send_progress_text(f"Luma video generation started: {response_api.id}", unique_id) + operation = PollingOperation( poll_endpoint=ApiEndpoint( path=f"/proxy/luma/generations/{response_api.id}", @@ -522,6 +544,9 @@ class LumaTextToVideoGenerationNode(ComfyNodeABC): completed_statuses=[LumaState.completed], failed_statuses=[LumaState.failed], status_extractor=lambda x: x.state, + result_url_extractor=video_result_url_extractor, + node_id=unique_id, + estimated_duration=LUMA_T2V_AVERAGE_DURATION, auth_kwargs=kwargs, ) response_poll = operation.execute() @@ -597,6 +622,7 @@ class LumaImageToVideoGenerationNode(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -611,6 +637,7 @@ class LumaImageToVideoGenerationNode(ComfyNodeABC): first_image: torch.Tensor = None, last_image: torch.Tensor = None, luma_concepts: LumaConceptChain = None, + unique_id: str = None, **kwargs, ): if first_image is None and last_image is None: @@ -642,6 +669,9 @@ class LumaImageToVideoGenerationNode(ComfyNodeABC): ) response_api: LumaGeneration = operation.execute() + if unique_id: + PromptServer.instance.send_progress_text(f"Luma video generation started: {response_api.id}", unique_id) + operation = PollingOperation( poll_endpoint=ApiEndpoint( path=f"/proxy/luma/generations/{response_api.id}", @@ -652,6 +682,9 @@ class LumaImageToVideoGenerationNode(ComfyNodeABC): completed_statuses=[LumaState.completed], failed_statuses=[LumaState.failed], status_extractor=lambda x: x.state, + result_url_extractor=video_result_url_extractor, + node_id=unique_id, + estimated_duration=LUMA_I2V_AVERAGE_DURATION, auth_kwargs=kwargs, ) response_poll = operation.execute() diff --git a/comfy_api_nodes/nodes_minimax.py b/comfy_api_nodes/nodes_minimax.py index fd64aeb0..9b46636d 100644 --- a/comfy_api_nodes/nodes_minimax.py +++ b/comfy_api_nodes/nodes_minimax.py @@ -1,3 +1,7 @@ +from typing import Union +import logging +import torch + from comfy.comfy_types.node_typing import IO from comfy_api.input_impl.video_types import VideoFromFile from comfy_api_nodes.apis import ( @@ -20,16 +24,19 @@ from comfy_api_nodes.apinode_utils import ( upload_images_to_comfyapi, validate_string, ) +from server import PromptServer -import torch -import logging +I2V_AVERAGE_DURATION = 114 +T2V_AVERAGE_DURATION = 234 class MinimaxTextToVideoNode: """ Generates videos synchronously based on a prompt, and optional parameters using MiniMax's API. """ + AVERAGE_DURATION = T2V_AVERAGE_DURATION + @classmethod def INPUT_TYPES(s): return { @@ -68,6 +75,7 @@ class MinimaxTextToVideoNode: "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -85,6 +93,7 @@ class MinimaxTextToVideoNode: model="T2V-01", image: torch.Tensor=None, # used for ImageToVideo subject: torch.Tensor=None, # used for SubjectToVideo + unique_id: Union[str, None]=None, **kwargs, ): ''' @@ -138,6 +147,8 @@ class MinimaxTextToVideoNode: completed_statuses=["Success"], failed_statuses=["Fail"], status_extractor=lambda x: x.status.value, + estimated_duration=self.AVERAGE_DURATION, + node_id=unique_id, auth_kwargs=kwargs, ) task_result = video_generate_operation.execute() @@ -164,6 +175,12 @@ class MinimaxTextToVideoNode: f"No video was found in the response. Full response: {file_result.model_dump()}" ) logging.info(f"Generated video URL: {file_url}") + if 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, unique_id) video_io = download_url_to_bytesio(file_url) if video_io is None: @@ -178,6 +195,8 @@ class MinimaxImageToVideoNode(MinimaxTextToVideoNode): Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API. """ + AVERAGE_DURATION = I2V_AVERAGE_DURATION + @classmethod def INPUT_TYPES(s): return { @@ -223,6 +242,7 @@ class MinimaxImageToVideoNode(MinimaxTextToVideoNode): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -239,6 +259,8 @@ class MinimaxSubjectToVideoNode(MinimaxTextToVideoNode): Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API. """ + AVERAGE_DURATION = T2V_AVERAGE_DURATION + @classmethod def INPUT_TYPES(s): return { @@ -282,6 +304,7 @@ class MinimaxSubjectToVideoNode(MinimaxTextToVideoNode): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } diff --git a/comfy_api_nodes/nodes_openai.py b/comfy_api_nodes/nodes_openai.py index c63908be..ce8054af 100644 --- a/comfy_api_nodes/nodes_openai.py +++ b/comfy_api_nodes/nodes_openai.py @@ -96,6 +96,7 @@ class OpenAIDalle2(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -113,6 +114,7 @@ class OpenAIDalle2(ComfyNodeABC): mask=None, n=1, size="1024x1024", + unique_id=None, **kwargs ): validate_string(prompt, strip_whitespace=False) @@ -176,7 +178,7 @@ class OpenAIDalle2(ComfyNodeABC): response = operation.execute() - img_tensor = validate_and_cast_response(response) + img_tensor = validate_and_cast_response(response, node_id=unique_id) return (img_tensor,) @@ -242,6 +244,7 @@ class OpenAIDalle3(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -258,6 +261,7 @@ class OpenAIDalle3(ComfyNodeABC): style="natural", quality="standard", size="1024x1024", + unique_id=None, **kwargs ): validate_string(prompt, strip_whitespace=False) @@ -284,7 +288,7 @@ class OpenAIDalle3(ComfyNodeABC): response = operation.execute() - img_tensor = validate_and_cast_response(response) + img_tensor = validate_and_cast_response(response, node_id=unique_id) return (img_tensor,) @@ -375,6 +379,7 @@ class OpenAIGPTImage1(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -394,6 +399,7 @@ class OpenAIGPTImage1(ComfyNodeABC): mask=None, n=1, size="1024x1024", + unique_id=None, **kwargs ): validate_string(prompt, strip_whitespace=False) @@ -476,7 +482,7 @@ class OpenAIGPTImage1(ComfyNodeABC): response = operation.execute() - img_tensor = validate_and_cast_response(response) + img_tensor = validate_and_cast_response(response, node_id=unique_id) return (img_tensor,) diff --git a/comfy_api_nodes/nodes_pika.py b/comfy_api_nodes/nodes_pika.py index 08ec9cf0..30562790 100644 --- a/comfy_api_nodes/nodes_pika.py +++ b/comfy_api_nodes/nodes_pika.py @@ -121,7 +121,10 @@ class PikaNodeBase(ComfyNodeABC): RETURN_TYPES = ("VIDEO",) def poll_for_task_status( - self, task_id: str, auth_kwargs: Optional[dict[str,str]] = None + self, + task_id: str, + auth_kwargs: Optional[dict[str, str]] = None, + node_id: Optional[str] = None, ) -> PikaGenerateResponse: polling_operation = PollingOperation( poll_endpoint=ApiEndpoint( @@ -141,13 +144,19 @@ class PikaNodeBase(ComfyNodeABC): response.progress if hasattr(response, "progress") else None ), auth_kwargs=auth_kwargs, + result_url_extractor=lambda response: ( + response.url if hasattr(response, "url") else None + ), + node_id=node_id, + estimated_duration=60 ) return polling_operation.execute() def execute_task( self, initial_operation: SynchronousOperation[R, PikaGenerateResponse], - auth_kwargs: Optional[dict[str,str]] = None, + auth_kwargs: Optional[dict[str, str]] = None, + node_id: Optional[str] = None, ) -> tuple[VideoFromFile]: """Executes the initial operation then polls for the task status until it is completed. @@ -208,7 +217,8 @@ class PikaImageToVideoV2_2(PikaNodeBase): seed: int, resolution: str, duration: int, - **kwargs + unique_id: str, + **kwargs, ) -> tuple[VideoFromFile]: # Convert image to BytesIO image_bytes_io = tensor_to_bytesio(image) @@ -238,7 +248,7 @@ class PikaImageToVideoV2_2(PikaNodeBase): auth_kwargs=kwargs, ) - return self.execute_task(initial_operation, auth_kwargs=kwargs) + return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id) class PikaTextToVideoNodeV2_2(PikaNodeBase): @@ -262,6 +272,7 @@ class PikaTextToVideoNodeV2_2(PikaNodeBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -275,6 +286,7 @@ class PikaTextToVideoNodeV2_2(PikaNodeBase): resolution: str, duration: int, aspect_ratio: float, + unique_id: str, **kwargs, ) -> tuple[VideoFromFile]: initial_operation = SynchronousOperation( @@ -296,7 +308,7 @@ class PikaTextToVideoNodeV2_2(PikaNodeBase): content_type="application/x-www-form-urlencoded", ) - return self.execute_task(initial_operation, auth_kwargs=kwargs) + return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id) class PikaScenesV2_2(PikaNodeBase): @@ -340,6 +352,7 @@ class PikaScenesV2_2(PikaNodeBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -354,6 +367,7 @@ class PikaScenesV2_2(PikaNodeBase): duration: int, ingredients_mode: str, aspect_ratio: float, + unique_id: str, image_ingredient_1: Optional[torch.Tensor] = None, image_ingredient_2: Optional[torch.Tensor] = None, image_ingredient_3: Optional[torch.Tensor] = None, @@ -403,7 +417,7 @@ class PikaScenesV2_2(PikaNodeBase): auth_kwargs=kwargs, ) - return self.execute_task(initial_operation, auth_kwargs=kwargs) + return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id) class PikAdditionsNode(PikaNodeBase): @@ -439,6 +453,7 @@ class PikAdditionsNode(PikaNodeBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -451,6 +466,7 @@ class PikAdditionsNode(PikaNodeBase): prompt_text: str, negative_prompt: str, seed: int, + unique_id: str, **kwargs, ) -> tuple[VideoFromFile]: # Convert video to BytesIO @@ -487,7 +503,7 @@ class PikAdditionsNode(PikaNodeBase): auth_kwargs=kwargs, ) - return self.execute_task(initial_operation, auth_kwargs=kwargs) + return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id) class PikaSwapsNode(PikaNodeBase): @@ -532,6 +548,7 @@ class PikaSwapsNode(PikaNodeBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -546,6 +563,7 @@ class PikaSwapsNode(PikaNodeBase): prompt_text: str, negative_prompt: str, seed: int, + unique_id: str, **kwargs, ) -> tuple[VideoFromFile]: # Convert video to BytesIO @@ -592,7 +610,7 @@ class PikaSwapsNode(PikaNodeBase): auth_kwargs=kwargs, ) - return self.execute_task(initial_operation, auth_kwargs=kwargs) + return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id) class PikaffectsNode(PikaNodeBase): @@ -637,6 +655,7 @@ class PikaffectsNode(PikaNodeBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -649,6 +668,7 @@ class PikaffectsNode(PikaNodeBase): prompt_text: str, negative_prompt: str, seed: int, + unique_id: str, **kwargs, ) -> tuple[VideoFromFile]: @@ -670,7 +690,7 @@ class PikaffectsNode(PikaNodeBase): auth_kwargs=kwargs, ) - return self.execute_task(initial_operation, auth_kwargs=kwargs) + return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id) class PikaStartEndFrameNode2_2(PikaNodeBase): @@ -689,6 +709,7 @@ class PikaStartEndFrameNode2_2(PikaNodeBase): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -703,6 +724,7 @@ class PikaStartEndFrameNode2_2(PikaNodeBase): seed: int, resolution: str, duration: int, + unique_id: str, **kwargs, ) -> tuple[VideoFromFile]: @@ -733,7 +755,7 @@ class PikaStartEndFrameNode2_2(PikaNodeBase): auth_kwargs=kwargs, ) - return self.execute_task(initial_operation, auth_kwargs=kwargs) + return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id) NODE_CLASS_MAPPINGS = { diff --git a/comfy_api_nodes/nodes_pixverse.py b/comfy_api_nodes/nodes_pixverse.py index 0c29e77c..ef4a9a80 100644 --- a/comfy_api_nodes/nodes_pixverse.py +++ b/comfy_api_nodes/nodes_pixverse.py @@ -1,5 +1,5 @@ from inspect import cleandoc - +from typing import Optional from comfy_api_nodes.apis.pixverse_api import ( PixverseTextVideoRequest, PixverseImageVideoRequest, @@ -34,11 +34,22 @@ import requests from io import BytesIO +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) + + def upload_image_to_pixverse(image: torch.Tensor, auth_kwargs=None): # first, upload image to Pixverse and get image id to use in actual generation call - files = { - "image": tensor_to_bytesio(image) - } + files = {"image": tensor_to_bytesio(image)} operation = SynchronousOperation( endpoint=ApiEndpoint( path="/proxy/pixverse/image/upload", @@ -54,7 +65,9 @@ def upload_image_to_pixverse(image: torch.Tensor, auth_kwargs=None): response_upload: PixverseImageUploadResponse = operation.execute() if response_upload.Resp is None: - raise Exception(f"PixVerse image upload request failed: '{response_upload.ErrMsg}'") + raise Exception( + f"PixVerse image upload request failed: '{response_upload.ErrMsg}'" + ) return response_upload.Resp.img_id @@ -73,7 +86,7 @@ class PixverseTemplateNode: def INPUT_TYPES(s): return { "required": { - "template": (list(pixverse_templates.keys()), ), + "template": (list(pixverse_templates.keys()),), } } @@ -87,7 +100,7 @@ class PixverseTemplateNode: class PixverseTextToVideoNode(ComfyNodeABC): """ - Generates videos synchronously based on prompt and output_size. + Generates videos based on prompt and output_size. """ RETURN_TYPES = (IO.VIDEO,) @@ -108,9 +121,7 @@ class PixverseTextToVideoNode(ComfyNodeABC): "tooltip": "Prompt for the video generation", }, ), - "aspect_ratio": ( - [ratio.value for ratio in PixverseAspectRatio], - ), + "aspect_ratio": ([ratio.value for ratio in PixverseAspectRatio],), "quality": ( [resolution.value for resolution in PixverseQuality], { @@ -143,12 +154,13 @@ class PixverseTextToVideoNode(ComfyNodeABC): PixverseIO.TEMPLATE, { "tooltip": "An optional template to influence style of generation, created by the PixVerse Template node." - } - ) + }, + ), }, "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -160,8 +172,9 @@ class PixverseTextToVideoNode(ComfyNodeABC): duration_seconds: int, motion_mode: str, seed, - negative_prompt: str=None, - pixverse_template: int=None, + negative_prompt: str = None, + pixverse_template: int = None, + unique_id: Optional[str] = None, **kwargs, ): validate_string(prompt, strip_whitespace=False) @@ -205,19 +218,27 @@ class PixverseTextToVideoNode(ComfyNodeABC): response_model=PixverseGenerationStatusResponse, ), completed_statuses=[PixverseStatus.successful], - failed_statuses=[PixverseStatus.contents_moderation, PixverseStatus.failed, PixverseStatus.deleted], + failed_statuses=[ + PixverseStatus.contents_moderation, + PixverseStatus.failed, + PixverseStatus.deleted, + ], status_extractor=lambda x: x.Resp.status, auth_kwargs=kwargs, + node_id=unique_id, + result_url_extractor=get_video_url_from_response, + estimated_duration=AVERAGE_DURATION_T2V, ) response_poll = operation.execute() vid_response = requests.get(response_poll.Resp.url) + return (VideoFromFile(BytesIO(vid_response.content)),) class PixverseImageToVideoNode(ComfyNodeABC): """ - Generates videos synchronously based on prompt and output_size. + Generates videos based on prompt and output_size. """ RETURN_TYPES = (IO.VIDEO,) @@ -230,9 +251,7 @@ class PixverseImageToVideoNode(ComfyNodeABC): def INPUT_TYPES(s): return { "required": { - "image": ( - IO.IMAGE, - ), + "image": (IO.IMAGE,), "prompt": ( IO.STRING, { @@ -273,12 +292,13 @@ class PixverseImageToVideoNode(ComfyNodeABC): PixverseIO.TEMPLATE, { "tooltip": "An optional template to influence style of generation, created by the PixVerse Template node." - } - ) + }, + ), }, "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -290,8 +310,9 @@ class PixverseImageToVideoNode(ComfyNodeABC): duration_seconds: int, motion_mode: str, seed, - negative_prompt: str=None, - pixverse_template: int=None, + negative_prompt: str = None, + pixverse_template: int = None, + unique_id: Optional[str] = None, **kwargs, ): validate_string(prompt, strip_whitespace=False) @@ -337,9 +358,16 @@ class PixverseImageToVideoNode(ComfyNodeABC): response_model=PixverseGenerationStatusResponse, ), completed_statuses=[PixverseStatus.successful], - failed_statuses=[PixverseStatus.contents_moderation, PixverseStatus.failed, PixverseStatus.deleted], + failed_statuses=[ + PixverseStatus.contents_moderation, + PixverseStatus.failed, + PixverseStatus.deleted, + ], status_extractor=lambda x: x.Resp.status, auth_kwargs=kwargs, + node_id=unique_id, + result_url_extractor=get_video_url_from_response, + estimated_duration=AVERAGE_DURATION_I2V, ) response_poll = operation.execute() @@ -349,7 +377,7 @@ class PixverseImageToVideoNode(ComfyNodeABC): class PixverseTransitionVideoNode(ComfyNodeABC): """ - Generates videos synchronously based on prompt and output_size. + Generates videos based on prompt and output_size. """ RETURN_TYPES = (IO.VIDEO,) @@ -362,12 +390,8 @@ class PixverseTransitionVideoNode(ComfyNodeABC): def INPUT_TYPES(s): return { "required": { - "first_frame": ( - IO.IMAGE, - ), - "last_frame": ( - IO.IMAGE, - ), + "first_frame": (IO.IMAGE,), + "last_frame": (IO.IMAGE,), "prompt": ( IO.STRING, { @@ -408,6 +432,7 @@ class PixverseTransitionVideoNode(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -420,7 +445,8 @@ class PixverseTransitionVideoNode(ComfyNodeABC): duration_seconds: int, motion_mode: str, seed, - negative_prompt: str=None, + negative_prompt: str = None, + unique_id: Optional[str] = None, **kwargs, ): validate_string(prompt, strip_whitespace=False) @@ -467,9 +493,16 @@ class PixverseTransitionVideoNode(ComfyNodeABC): response_model=PixverseGenerationStatusResponse, ), completed_statuses=[PixverseStatus.successful], - failed_statuses=[PixverseStatus.contents_moderation, PixverseStatus.failed, PixverseStatus.deleted], + failed_statuses=[ + PixverseStatus.contents_moderation, + PixverseStatus.failed, + PixverseStatus.deleted, + ], status_extractor=lambda x: x.Resp.status, auth_kwargs=kwargs, + node_id=unique_id, + result_url_extractor=get_video_url_from_response, + estimated_duration=AVERAGE_DURATION_T2V, ) response_poll = operation.execute() diff --git a/comfy_api_nodes/nodes_recraft.py b/comfy_api_nodes/nodes_recraft.py index 767d93e3..e369c4b7 100644 --- a/comfy_api_nodes/nodes_recraft.py +++ b/comfy_api_nodes/nodes_recraft.py @@ -1,5 +1,6 @@ from __future__ import annotations from inspect import cleandoc +from typing import Optional from comfy.utils import ProgressBar from comfy_extras.nodes_images import SVG # Added from comfy.comfy_types.node_typing import IO @@ -29,6 +30,8 @@ from comfy_api_nodes.apinode_utils import ( resize_mask_to_image, validate_string, ) +from server import PromptServer + import torch from io import BytesIO from PIL import UnidentifiedImageError @@ -388,6 +391,7 @@ class RecraftTextToImageNode: "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -400,6 +404,7 @@ class RecraftTextToImageNode: recraft_style: RecraftStyle = None, negative_prompt: str = None, recraft_controls: RecraftControls = None, + unique_id: Optional[str] = None, **kwargs, ): validate_string(prompt, strip_whitespace=False, max_length=1000) @@ -436,8 +441,15 @@ class RecraftTextToImageNode: ) response: RecraftImageGenerationResponse = operation.execute() images = [] + urls = [] for data in response.data: with handle_recraft_image_output(): + if unique_id and data.url: + urls.append(data.url) + urls_string = '\n'.join(urls) + PromptServer.instance.send_progress_text( + f"Result URL: {urls_string}", unique_id + ) image = bytesio_to_image_tensor( download_url_to_bytesio(data.url, timeout=1024) ) @@ -763,6 +775,7 @@ class RecraftTextToVectorNode: "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -775,6 +788,7 @@ class RecraftTextToVectorNode: seed, negative_prompt: str = None, recraft_controls: RecraftControls = None, + unique_id: Optional[str] = None, **kwargs, ): validate_string(prompt, strip_whitespace=False, max_length=1000) @@ -809,7 +823,14 @@ class RecraftTextToVectorNode: ) response: RecraftImageGenerationResponse = operation.execute() svg_data = [] + urls = [] for data in response.data: + if unique_id and data.url: + urls.append(data.url) + # Print result on each iteration in case of error + PromptServer.instance.send_progress_text( + f"Result URL: {' '.join(urls)}", unique_id + ) svg_data.append(download_url_to_bytesio(data.url, timeout=1024)) return (SVG(svg_data),) diff --git a/comfy_api_nodes/nodes_veo2.py b/comfy_api_nodes/nodes_veo2.py index 2740179c..df846d5d 100644 --- a/comfy_api_nodes/nodes_veo2.py +++ b/comfy_api_nodes/nodes_veo2.py @@ -3,6 +3,7 @@ import logging import base64 import requests import torch +from typing import Optional from comfy.comfy_types.node_typing import IO, ComfyNodeABC from comfy_api.input_impl.video_types import VideoFromFile @@ -24,6 +25,8 @@ from comfy_api_nodes.apinode_utils import ( tensor_to_base64_string ) +AVERAGE_DURATION_VIDEO_GEN = 32 + def convert_image_to_base64(image: torch.Tensor): if image is None: return None @@ -31,6 +34,22 @@ def convert_image_to_base64(image: torch.Tensor): scaled_image = downscale_image_tensor(image, total_pixels=2048*2048) return tensor_to_base64_string(scaled_image) + +def get_video_url_from_response(poll_response: Veo2GenVidPollResponse) -> Optional[str]: + if ( + poll_response.response + and hasattr(poll_response.response, "videos") + and poll_response.response.videos + and len(poll_response.response.videos) > 0 + ): + video = poll_response.response.videos[0] + else: + return None + if hasattr(video, "gcsUri") and video.gcsUri: + return str(video.gcsUri) + return None + + class VeoVideoGenerationNode(ComfyNodeABC): """ Generates videos from text prompts using Google's Veo API. @@ -115,6 +134,7 @@ class VeoVideoGenerationNode(ComfyNodeABC): "hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", }, } @@ -134,6 +154,7 @@ class VeoVideoGenerationNode(ComfyNodeABC): person_generation="ALLOW", seed=0, image=None, + unique_id: Optional[str] = None, **kwargs, ): # Prepare the instances for the request @@ -215,7 +236,10 @@ class VeoVideoGenerationNode(ComfyNodeABC): operationName=operation_name ), auth_kwargs=kwargs, - poll_interval=5.0 + poll_interval=5.0, + result_url_extractor=get_video_url_from_response, + node_id=unique_id, + estimated_duration=AVERAGE_DURATION_VIDEO_GEN, ) # Execute the polling operation