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
This commit is contained in:
Christian Byrne 2025-05-13 21:33:18 -07:00 committed by GitHub
parent bab836d88d
commit 98ff01e148
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 474 additions and 92 deletions

View File

@ -235,7 +235,7 @@ class ComfyNodeABC(ABC):
DEPRECATED: bool DEPRECATED: bool
"""Flags a node as deprecated, indicating to users that they should find alternatives to this node.""" """Flags a node as deprecated, indicating to users that they should find alternatives to this node."""
API_NODE: Optional[bool] 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 @classmethod
@abstractmethod @abstractmethod

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import io import io
import logging import logging
from typing import Optional from typing import Optional, Union
from comfy.utils import common_upscale from comfy.utils import common_upscale
from comfy_api.input_impl import VideoFromFile from comfy_api.input_impl import VideoFromFile
from comfy_api.util import VideoContainer, VideoCodec from comfy_api.util import VideoContainer, VideoCodec
@ -15,6 +15,7 @@ from comfy_api_nodes.apis.client import (
UploadRequest, UploadRequest,
UploadResponse, UploadResponse,
) )
from server import PromptServer
import numpy as np import numpy as np
@ -60,7 +61,9 @@ def downscale_image_tensor(image, total_pixels=1536 * 1024) -> torch.Tensor:
return s 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. """Validates and casts a response to a torch.Tensor.
Args: Args:
@ -94,6 +97,10 @@ def validate_and_cast_response(response, timeout: int = None) -> torch.Tensor:
img = Image.open(io.BytesIO(img_data)) img = Image.open(io.BytesIO(img_data))
elif image_url: 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) img_response = requests.get(image_url, timeout=timeout)
if img_response.status_code != 200: if img_response.status_code != 200:
raise ValueError("Failed to download the image") raise ValueError("Failed to download the image")

View File

@ -103,6 +103,7 @@ from urllib.parse import urljoin, urlparse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import uuid # For generating unique operation IDs import uuid # For generating unique operation IDs
from server import PromptServer
from comfy.cli_args import args from comfy.cli_args import args
from comfy import utils from comfy import utils
from . import request_logger from . import request_logger
@ -900,6 +901,7 @@ class PollingOperation(Generic[T, R]):
failed_statuses: list, failed_statuses: list,
status_extractor: Callable[[R], str], status_extractor: Callable[[R], str],
progress_extractor: Callable[[R], float] = None, progress_extractor: Callable[[R], float] = None,
result_url_extractor: Callable[[R], str] = None,
request: Optional[T] = None, request: Optional[T] = None,
api_base: str | None = None, api_base: str | None = None,
auth_token: Optional[str] = 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 max_retries: int = 3, # Max retries per individual API call
retry_delay: float = 1.0, retry_delay: float = 1.0,
retry_backoff_factor: float = 2.0, retry_backoff_factor: float = 2.0,
estimated_duration: Optional[float] = None,
node_id: Optional[str] = None,
): ):
self.poll_endpoint = poll_endpoint self.poll_endpoint = poll_endpoint
self.request = request self.request = request
@ -924,12 +928,15 @@ class PollingOperation(Generic[T, R]):
self.max_retries = max_retries self.max_retries = max_retries
self.retry_delay = retry_delay self.retry_delay = retry_delay
self.retry_backoff_factor = retry_backoff_factor self.retry_backoff_factor = retry_backoff_factor
self.estimated_duration = estimated_duration
# Polling configuration # Polling configuration
self.status_extractor = status_extractor or ( self.status_extractor = status_extractor or (
lambda x: getattr(x, "status", None) lambda x: getattr(x, "status", None)
) )
self.progress_extractor = progress_extractor self.progress_extractor = progress_extractor
self.result_url_extractor = result_url_extractor
self.node_id = node_id
self.completed_statuses = completed_statuses self.completed_statuses = completed_statuses
self.failed_statuses = failed_statuses self.failed_statuses = failed_statuses
@ -965,6 +972,26 @@ class PollingOperation(Generic[T, R]):
except Exception as e: except Exception as e:
raise Exception(f"Error during polling: {str(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: def _check_task_status(self, response: R) -> TaskStatus:
"""Check task status using the status extractor function""" """Check task status using the status extractor function"""
try: try:
@ -1031,7 +1058,15 @@ class PollingOperation(Generic[T, R]):
progress.update_absolute(new_progress, total=PROGRESS_BAR_MAX) progress.update_absolute(new_progress, total=PROGRESS_BAR_MAX)
if status == TaskStatus.COMPLETED: 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 self.final_response = response_obj
if self.progress_extractor: if self.progress_extractor:
progress.update(100) progress.update(100)
@ -1047,7 +1082,10 @@ class PollingOperation(Generic[T, R]):
logging.debug( logging.debug(
f"[DEBUG] Waiting {self.poll_interval} seconds before next poll" 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: except (LocalNetworkError, ApiServerError) as e:
# For network-related errors, increment error count and potentially abort # For network-related errors, increment error count and potentially abort

View File

@ -1,5 +1,6 @@
import io import io
from inspect import cleandoc from inspect import cleandoc
from typing import Union
from comfy.comfy_types.node_typing import IO, ComfyNodeABC from comfy.comfy_types.node_typing import IO, ComfyNodeABC
from comfy_api_nodes.apis.bfl_api import ( from comfy_api_nodes.apis.bfl_api import (
BFLStatus, BFLStatus,
@ -30,6 +31,7 @@ import requests
import torch import torch
import base64 import base64
import time import time
from server import PromptServer
def convert_mask_to_image(mask: torch.Tensor): 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( 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() response_api: BFLFluxProGenerateResponse = operation.execute()
return _poll_until_generated( 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: # used bfl-comfy-nodes to verify code implementation:
# https://github.com/black-forest-labs/bfl-comfy-nodes/tree/main # https://github.com/black-forest-labs/bfl-comfy-nodes/tree/main
start_time = time.time() 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) request = requests.Request(method=HttpMethod.GET, url=polling_url)
# NOTE: should True loop be replaced with checking if workflow has been interrupted? # NOTE: should True loop be replaced with checking if workflow has been interrupted?
while True: 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()) response = requests.Session().send(request.prepare())
if response.status_code == 200: if response.status_code == 200:
result = response.json() result = response.json()
if result["status"] == BFLStatus.ready: if result["status"] == BFLStatus.ready:
img_url = result["result"]["sample"] 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) img_response = requests.get(img_url)
return process_image_response(img_response) return process_image_response(img_response)
elif result["status"] in [ elif result["status"] in [
@ -180,6 +197,7 @@ class FluxProUltraImageNode(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -212,6 +230,7 @@ class FluxProUltraImageNode(ComfyNodeABC):
seed=0, seed=0,
image_prompt=None, image_prompt=None,
image_prompt_strength=0.1, image_prompt_strength=0.1,
unique_id: Union[str, None] = None,
**kwargs, **kwargs,
): ):
if image_prompt is None: if image_prompt is None:
@ -246,7 +265,7 @@ class FluxProUltraImageNode(ComfyNodeABC):
), ),
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
output_image = handle_bfl_synchronous_operation(operation) output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
return (output_image,) return (output_image,)
@ -320,6 +339,7 @@ class FluxProImageNode(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -338,6 +358,7 @@ class FluxProImageNode(ComfyNodeABC):
seed=0, seed=0,
image_prompt=None, image_prompt=None,
# image_prompt_strength=0.1, # image_prompt_strength=0.1,
unique_id: Union[str, None] = None,
**kwargs, **kwargs,
): ):
image_prompt = ( image_prompt = (
@ -363,7 +384,7 @@ class FluxProImageNode(ComfyNodeABC):
), ),
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
output_image = handle_bfl_synchronous_operation(operation) output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
return (output_image,) return (output_image,)
@ -457,11 +478,11 @@ class FluxProExpandNode(ComfyNodeABC):
}, },
), ),
}, },
"optional": { "optional": {},
},
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -483,6 +504,7 @@ class FluxProExpandNode(ComfyNodeABC):
steps: int, steps: int,
guidance: float, guidance: float,
seed=0, seed=0,
unique_id: Union[str, None] = None,
**kwargs, **kwargs,
): ):
image = convert_image_to_base64(image) image = convert_image_to_base64(image)
@ -508,7 +530,7 @@ class FluxProExpandNode(ComfyNodeABC):
), ),
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
output_image = handle_bfl_synchronous_operation(operation) output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
return (output_image,) return (output_image,)
@ -568,11 +590,11 @@ class FluxProFillNode(ComfyNodeABC):
}, },
), ),
}, },
"optional": { "optional": {},
},
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -591,13 +613,14 @@ class FluxProFillNode(ComfyNodeABC):
steps: int, steps: int,
guidance: float, guidance: float,
seed=0, seed=0,
unique_id: Union[str, None] = None,
**kwargs, **kwargs,
): ):
# prepare mask # prepare mask
mask = resize_mask_to_image(mask, image) mask = resize_mask_to_image(mask, image)
mask = convert_image_to_base64(convert_mask_to_image(mask)) mask = convert_image_to_base64(convert_mask_to_image(mask))
# make sure image will have alpha channel removed # make sure image will have alpha channel removed
image = convert_image_to_base64(image[:,:,:,:3]) image = convert_image_to_base64(image[:, :, :, :3])
operation = SynchronousOperation( operation = SynchronousOperation(
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
@ -617,7 +640,7 @@ class FluxProFillNode(ComfyNodeABC):
), ),
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
output_image = handle_bfl_synchronous_operation(operation) output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
return (output_image,) return (output_image,)
@ -702,11 +725,11 @@ class FluxProCannyNode(ComfyNodeABC):
}, },
), ),
}, },
"optional": { "optional": {},
},
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -727,9 +750,10 @@ class FluxProCannyNode(ComfyNodeABC):
steps: int, steps: int,
guidance: float, guidance: float,
seed=0, seed=0,
unique_id: Union[str, None] = None,
**kwargs, **kwargs,
): ):
control_image = convert_image_to_base64(control_image[:,:,:,:3]) control_image = convert_image_to_base64(control_image[:, :, :, :3])
preprocessed_image = None preprocessed_image = None
# scale canny threshold between 0-500, to match BFL's API # scale canny threshold between 0-500, to match BFL's API
@ -765,7 +789,7 @@ class FluxProCannyNode(ComfyNodeABC):
), ),
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
output_image = handle_bfl_synchronous_operation(operation) output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
return (output_image,) return (output_image,)
@ -830,11 +854,11 @@ class FluxProDepthNode(ComfyNodeABC):
}, },
), ),
}, },
"optional": { "optional": {},
},
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -853,6 +877,7 @@ class FluxProDepthNode(ComfyNodeABC):
steps: int, steps: int,
guidance: float, guidance: float,
seed=0, seed=0,
unique_id: Union[str, None] = None,
**kwargs, **kwargs,
): ):
control_image = convert_image_to_base64(control_image[:,:,:,:3]) control_image = convert_image_to_base64(control_image[:,:,:,:3])
@ -880,7 +905,7 @@ class FluxProDepthNode(ComfyNodeABC):
), ),
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
output_image = handle_bfl_synchronous_operation(operation) output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
return (output_image,) return (output_image,)

View File

@ -23,6 +23,7 @@ from comfy_api_nodes.apinode_utils import (
bytesio_to_image_tensor, bytesio_to_image_tensor,
resize_mask_to_image, resize_mask_to_image,
) )
from server import PromptServer
V1_V1_RES_MAP = { V1_V1_RES_MAP = {
"Auto":"AUTO", "Auto":"AUTO",
@ -232,6 +233,19 @@ def download_and_process_images(image_urls):
return stacked_tensors 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): class IdeogramV1(ComfyNodeABC):
""" """
Generates images using the Ideogram V1 model. Generates images using the Ideogram V1 model.
@ -304,6 +318,7 @@ class IdeogramV1(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -322,6 +337,7 @@ class IdeogramV1(ComfyNodeABC):
seed=0, seed=0,
negative_prompt="", negative_prompt="",
num_images=1, num_images=1,
unique_id=None,
**kwargs, **kwargs,
): ):
# Determine the model based on turbo setting # Determine the model based on turbo setting
@ -361,6 +377,7 @@ class IdeogramV1(ComfyNodeABC):
if not image_urls: if not image_urls:
raise Exception("No image URLs were generated in the response") 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),) return (download_and_process_images(image_urls),)
@ -460,6 +477,7 @@ class IdeogramV2(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -481,6 +499,7 @@ class IdeogramV2(ComfyNodeABC):
negative_prompt="", negative_prompt="",
num_images=1, num_images=1,
color_palette="", color_palette="",
unique_id=None,
**kwargs, **kwargs,
): ):
aspect_ratio = V1_V2_RATIO_MAP.get(aspect_ratio, None) aspect_ratio = V1_V2_RATIO_MAP.get(aspect_ratio, None)
@ -534,6 +553,7 @@ class IdeogramV2(ComfyNodeABC):
if not image_urls: if not image_urls:
raise Exception("No image URLs were generated in the response") 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),) return (download_and_process_images(image_urls),)
class IdeogramV3(ComfyNodeABC): class IdeogramV3(ComfyNodeABC):
@ -623,6 +643,7 @@ class IdeogramV3(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -643,6 +664,7 @@ class IdeogramV3(ComfyNodeABC):
seed=0, seed=0,
num_images=1, num_images=1,
rendering_speed="BALANCED", rendering_speed="BALANCED",
unique_id=None,
**kwargs, **kwargs,
): ):
# Check if both image and mask are provided for editing mode # Check if both image and mask are provided for editing mode
@ -762,6 +784,7 @@ class IdeogramV3(ComfyNodeABC):
if not image_urls: if not image_urls:
raise Exception("No image URLs were generated in the response") 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),) return (download_and_process_images(image_urls),)
@ -776,4 +799,3 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"IdeogramV2": "Ideogram V2", "IdeogramV2": "Ideogram V2",
"IdeogramV3": "Ideogram V3", "IdeogramV3": "Ideogram V3",
} }

View File

@ -6,6 +6,7 @@ For source of truth on the allowed permutations of request fields, please refere
from __future__ import annotations from __future__ import annotations
from typing import Optional, TypeVar, Any from typing import Optional, TypeVar, Any
from collections.abc import Callable
import math import math
import logging import logging
@ -86,6 +87,15 @@ MAX_PROMPT_LENGTH_IMAGE_GEN = 500
MAX_NEGATIVE_PROMPT_LENGTH_IMAGE_GEN = 200 MAX_NEGATIVE_PROMPT_LENGTH_IMAGE_GEN = 200
MAX_PROMPT_LENGTH_LIP_SYNC = 120 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") R = TypeVar("R")
@ -95,7 +105,13 @@ class KlingApiError(Exception):
pass 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.""" """Polls the Kling API endpoint until the task reaches a terminal state, then returns the response."""
return PollingOperation( return PollingOperation(
poll_endpoint=api_endpoint, poll_endpoint=api_endpoint,
@ -109,6 +125,9 @@ def poll_until_finished(auth_kwargs: dict[str,str], api_endpoint: ApiEndpoint[An
else None else None
), ),
auth_kwargs=auth_kwargs, auth_kwargs=auth_kwargs,
result_url_extractor=result_url_extractor,
estimated_duration=estimated_duration,
node_id=node_id,
).execute() ).execute()
@ -227,7 +246,9 @@ def get_camera_control_input_config(
def get_video_from_response(response) -> KlingVideoResult: 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] video = response.data.task_result.videos[0]
logging.info( logging.info(
"Kling task %s succeeded. Video URL: %s", response.data.task_id, video.url "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 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]: 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 images = response.data.task_result.images
logging.info("Kling task %s succeeded. Images: %s", response.data.task_id, images) logging.info("Kling task %s succeeded. Images: %s", response.data.task_id, images)
return 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( def video_result_to_node_output(
video: KlingVideoResult, video: KlingVideoResult,
) -> tuple[VideoFromFile, str, str]: ) -> tuple[VideoFromFile, str, str]:
@ -312,6 +358,7 @@ class KlingCameraControls(KlingNodeBase):
RETURN_TYPES = ("CAMERA_CONTROL",) RETURN_TYPES = ("CAMERA_CONTROL",)
RETURN_NAMES = ("camera_control",) RETURN_NAMES = ("camera_control",)
FUNCTION = "main" FUNCTION = "main"
API_NODE = False # This is just a helper node, it doesn't make an API call
@classmethod @classmethod
def VALIDATE_INPUTS( def VALIDATE_INPUTS(
@ -421,6 +468,7 @@ class KlingTextToVideoNode(KlingNodeBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_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") RETURN_NAMES = ("VIDEO", "video_id", "duration")
DESCRIPTION = "Kling Text to Video Node" 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( return poll_until_finished(
auth_kwargs, auth_kwargs,
ApiEndpoint( ApiEndpoint(
@ -437,6 +487,9 @@ class KlingTextToVideoNode(KlingNodeBase):
request_model=EmptyRequest, request_model=EmptyRequest,
response_model=KlingText2VideoResponse, response_model=KlingText2VideoResponse,
), ),
result_url_extractor=get_video_url_from_response,
estimated_duration=AVERAGE_DURATION_T2V,
node_id=node_id,
) )
def api_call( def api_call(
@ -449,6 +502,7 @@ class KlingTextToVideoNode(KlingNodeBase):
camera_control: Optional[KlingCameraControl] = None, camera_control: Optional[KlingCameraControl] = None,
model_name: Optional[str] = None, model_name: Optional[str] = None,
duration: Optional[str] = None, duration: Optional[str] = None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
) -> tuple[VideoFromFile, str, str]: ) -> tuple[VideoFromFile, str, str]:
validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V) validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V)
@ -478,7 +532,9 @@ class KlingTextToVideoNode(KlingNodeBase):
validate_task_creation_response(task_creation_response) validate_task_creation_response(task_creation_response)
task_id = task_creation_response.data.task_id 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) validate_video_result_response(final_response)
video = get_video_from_response(final_response) video = get_video_from_response(final_response)
@ -528,6 +584,7 @@ class KlingCameraControlT2VNode(KlingTextToVideoNode):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -540,6 +597,7 @@ class KlingCameraControlT2VNode(KlingTextToVideoNode):
cfg_scale: float, cfg_scale: float,
aspect_ratio: str, aspect_ratio: str,
camera_control: Optional[KlingCameraControl] = None, camera_control: Optional[KlingCameraControl] = None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
return super().api_call( return super().api_call(
@ -613,6 +671,7 @@ class KlingImage2VideoNode(KlingNodeBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_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") RETURN_NAMES = ("VIDEO", "video_id", "duration")
DESCRIPTION = "Kling Image to Video Node" 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( return poll_until_finished(
auth_kwargs, auth_kwargs,
ApiEndpoint( ApiEndpoint(
@ -629,6 +690,9 @@ class KlingImage2VideoNode(KlingNodeBase):
request_model=KlingImage2VideoRequest, request_model=KlingImage2VideoRequest,
response_model=KlingImage2VideoResponse, response_model=KlingImage2VideoResponse,
), ),
result_url_extractor=get_video_url_from_response,
estimated_duration=AVERAGE_DURATION_I2V,
node_id=node_id,
) )
def api_call( def api_call(
@ -643,6 +707,7 @@ class KlingImage2VideoNode(KlingNodeBase):
duration: str, duration: str,
camera_control: Optional[KlingCameraControl] = None, camera_control: Optional[KlingCameraControl] = None,
end_frame: Optional[torch.Tensor] = None, end_frame: Optional[torch.Tensor] = None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
) -> tuple[VideoFromFile]: ) -> tuple[VideoFromFile]:
validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_I2V) validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_I2V)
@ -681,7 +746,9 @@ class KlingImage2VideoNode(KlingNodeBase):
validate_task_creation_response(task_creation_response) validate_task_creation_response(task_creation_response)
task_id = task_creation_response.data.task_id 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) validate_video_result_response(final_response)
video = get_video_from_response(final_response) video = get_video_from_response(final_response)
@ -734,6 +801,7 @@ class KlingCameraControlI2VNode(KlingImage2VideoNode):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -747,6 +815,7 @@ class KlingCameraControlI2VNode(KlingImage2VideoNode):
cfg_scale: float, cfg_scale: float,
aspect_ratio: str, aspect_ratio: str,
camera_control: KlingCameraControl, camera_control: KlingCameraControl,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
return super().api_call( return super().api_call(
@ -759,6 +828,7 @@ class KlingCameraControlI2VNode(KlingImage2VideoNode):
prompt=prompt, prompt=prompt,
negative_prompt=negative_prompt, negative_prompt=negative_prompt,
camera_control=camera_control, camera_control=camera_control,
unique_id=unique_id,
**kwargs, **kwargs,
) )
@ -830,6 +900,7 @@ class KlingStartEndFrameNode(KlingImage2VideoNode):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -844,6 +915,7 @@ class KlingStartEndFrameNode(KlingImage2VideoNode):
cfg_scale: float, cfg_scale: float,
aspect_ratio: str, aspect_ratio: str,
mode: str, mode: str,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
mode, duration, model_name = KlingStartEndFrameNode.get_mode_string_mapping()[ mode, duration, model_name = KlingStartEndFrameNode.get_mode_string_mapping()[
@ -859,6 +931,7 @@ class KlingStartEndFrameNode(KlingImage2VideoNode):
aspect_ratio=aspect_ratio, aspect_ratio=aspect_ratio,
duration=duration, duration=duration,
end_frame=end_frame, end_frame=end_frame,
unique_id=unique_id,
**kwargs, **kwargs,
) )
@ -892,6 +965,7 @@ class KlingVideoExtendNode(KlingNodeBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_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") 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." 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( return poll_until_finished(
auth_kwargs, auth_kwargs,
ApiEndpoint( ApiEndpoint(
@ -908,6 +984,9 @@ class KlingVideoExtendNode(KlingNodeBase):
request_model=EmptyRequest, request_model=EmptyRequest,
response_model=KlingVideoExtendResponse, response_model=KlingVideoExtendResponse,
), ),
result_url_extractor=get_video_url_from_response,
estimated_duration=AVERAGE_DURATION_VIDEO_EXTEND,
node_id=node_id,
) )
def api_call( def api_call(
@ -916,6 +995,7 @@ class KlingVideoExtendNode(KlingNodeBase):
negative_prompt: str, negative_prompt: str,
cfg_scale: float, cfg_scale: float,
video_id: str, video_id: str,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
) -> tuple[VideoFromFile, str, str]: ) -> tuple[VideoFromFile, str, str]:
validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V) validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V)
@ -939,7 +1019,9 @@ class KlingVideoExtendNode(KlingNodeBase):
validate_task_creation_response(task_creation_response) validate_task_creation_response(task_creation_response)
task_id = task_creation_response.data.task_id 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) validate_video_result_response(final_response)
video = get_video_from_response(final_response) video = get_video_from_response(final_response)
@ -952,7 +1034,9 @@ class KlingVideoEffectsBase(KlingNodeBase):
RETURN_TYPES = ("VIDEO", "STRING", "STRING") RETURN_TYPES = ("VIDEO", "STRING", "STRING")
RETURN_NAMES = ("VIDEO", "video_id", "duration") 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( return poll_until_finished(
auth_kwargs, auth_kwargs,
ApiEndpoint( ApiEndpoint(
@ -961,6 +1045,9 @@ class KlingVideoEffectsBase(KlingNodeBase):
request_model=EmptyRequest, request_model=EmptyRequest,
response_model=KlingVideoEffectsResponse, response_model=KlingVideoEffectsResponse,
), ),
result_url_extractor=get_video_url_from_response,
estimated_duration=AVERAGE_DURATION_VIDEO_EFFECTS,
node_id=node_id,
) )
def api_call( def api_call(
@ -972,6 +1059,7 @@ class KlingVideoEffectsBase(KlingNodeBase):
image_1: torch.Tensor, image_1: torch.Tensor,
image_2: Optional[torch.Tensor] = None, image_2: Optional[torch.Tensor] = None,
mode: Optional[KlingVideoGenMode] = None, mode: Optional[KlingVideoGenMode] = None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
if dual_character: if dual_character:
@ -1009,7 +1097,9 @@ class KlingVideoEffectsBase(KlingNodeBase):
validate_task_creation_response(task_creation_response) validate_task_creation_response(task_creation_response)
task_id = task_creation_response.data.task_id 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) validate_video_result_response(final_response)
video = get_video_from_response(final_response) video = get_video_from_response(final_response)
@ -1053,6 +1143,7 @@ class KlingDualCharacterVideoEffectNode(KlingVideoEffectsBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -1068,6 +1159,7 @@ class KlingDualCharacterVideoEffectNode(KlingVideoEffectsBase):
model_name: KlingCharacterEffectModelName, model_name: KlingCharacterEffectModelName,
mode: KlingVideoGenMode, mode: KlingVideoGenMode,
duration: KlingVideoGenDuration, duration: KlingVideoGenDuration,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
video, _, duration = super().api_call( video, _, duration = super().api_call(
@ -1078,10 +1170,12 @@ class KlingDualCharacterVideoEffectNode(KlingVideoEffectsBase):
duration=duration, duration=duration,
image_1=image_left, image_1=image_left,
image_2=image_right, image_2=image_right,
unique_id=unique_id,
**kwargs, **kwargs,
) )
return video, duration return video, duration
class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase): class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase):
"""Kling Single Image Video Effect Node""" """Kling Single Image Video Effect Node"""
@ -1117,6 +1211,7 @@ class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -1128,6 +1223,7 @@ class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase):
effect_scene: KlingSingleImageEffectsScene, effect_scene: KlingSingleImageEffectsScene,
model_name: KlingSingleImageEffectModelName, model_name: KlingSingleImageEffectModelName,
duration: KlingVideoGenDuration, duration: KlingVideoGenDuration,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
return super().api_call( return super().api_call(
@ -1136,6 +1232,7 @@ class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase):
model_name=model_name, model_name=model_name,
duration=duration, duration=duration,
image_1=image, image_1=image,
unique_id=unique_id,
**kwargs, **kwargs,
) )
@ -1154,7 +1251,9 @@ class KlingLipSyncBase(KlingNodeBase):
f"Text is too long. Maximum length is {MAX_PROMPT_LENGTH_LIP_SYNC} characters." 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.""" """Polls the Kling API endpoint until the task reaches a terminal state."""
return poll_until_finished( return poll_until_finished(
auth_kwargs, auth_kwargs,
@ -1164,6 +1263,9 @@ class KlingLipSyncBase(KlingNodeBase):
request_model=EmptyRequest, request_model=EmptyRequest,
response_model=KlingLipSyncResponse, response_model=KlingLipSyncResponse,
), ),
result_url_extractor=get_video_url_from_response,
estimated_duration=AVERAGE_DURATION_LIP_SYNC,
node_id=node_id,
) )
def api_call( def api_call(
@ -1175,7 +1277,8 @@ class KlingLipSyncBase(KlingNodeBase):
text: Optional[str] = None, text: Optional[str] = None,
voice_speed: Optional[float] = None, voice_speed: Optional[float] = None,
voice_id: Optional[str] = None, voice_id: Optional[str] = None,
**kwargs unique_id: Optional[str] = None,
**kwargs,
) -> tuple[VideoFromFile, str, str]: ) -> tuple[VideoFromFile, str, str]:
if text: if text:
self.validate_text(text) self.validate_text(text)
@ -1217,7 +1320,9 @@ class KlingLipSyncBase(KlingNodeBase):
validate_task_creation_response(task_creation_response) validate_task_creation_response(task_creation_response)
task_id = task_creation_response.data.task_id 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) validate_video_result_response(final_response)
video = get_video_from_response(final_response) video = get_video_from_response(final_response)
@ -1243,6 +1348,7 @@ class KlingLipSyncAudioToVideoNode(KlingLipSyncBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -1253,6 +1359,7 @@ class KlingLipSyncAudioToVideoNode(KlingLipSyncBase):
video: VideoInput, video: VideoInput,
audio: AudioInput, audio: AudioInput,
voice_language: str, voice_language: str,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
return super().api_call( return super().api_call(
@ -1260,6 +1367,7 @@ class KlingLipSyncAudioToVideoNode(KlingLipSyncBase):
audio=audio, audio=audio,
voice_language=voice_language, voice_language=voice_language,
mode="audio2video", mode="audio2video",
unique_id=unique_id,
**kwargs, **kwargs,
) )
@ -1352,6 +1460,7 @@ class KlingLipSyncTextToVideoNode(KlingLipSyncBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -1363,6 +1472,7 @@ class KlingLipSyncTextToVideoNode(KlingLipSyncBase):
text: str, text: str,
voice: str, voice: str,
voice_speed: float, voice_speed: float,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
voice_id, voice_language = KlingLipSyncTextToVideoNode.get_voice_config()[voice] voice_id, voice_language = KlingLipSyncTextToVideoNode.get_voice_config()[voice]
@ -1373,6 +1483,7 @@ class KlingLipSyncTextToVideoNode(KlingLipSyncBase):
voice_id=voice_id, voice_id=voice_id,
voice_speed=voice_speed, voice_speed=voice_speed,
mode="text2video", mode="text2video",
unique_id=unique_id,
**kwargs, **kwargs,
) )
@ -1413,13 +1524,14 @@ class KlingVirtualTryOnNode(KlingImageGenerationBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_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( 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: ) -> KlingVirtualTryOnResponse:
return poll_until_finished( return poll_until_finished(
auth_kwargs, auth_kwargs,
@ -1429,6 +1541,9 @@ class KlingVirtualTryOnNode(KlingImageGenerationBase):
request_model=EmptyRequest, request_model=EmptyRequest,
response_model=KlingVirtualTryOnResponse, 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( def api_call(
@ -1436,6 +1551,7 @@ class KlingVirtualTryOnNode(KlingImageGenerationBase):
human_image: torch.Tensor, human_image: torch.Tensor,
cloth_image: torch.Tensor, cloth_image: torch.Tensor,
model_name: KlingVirtualTryOnModelName, model_name: KlingVirtualTryOnModelName,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
initial_operation = SynchronousOperation( initial_operation = SynchronousOperation(
@ -1457,7 +1573,9 @@ class KlingVirtualTryOnNode(KlingImageGenerationBase):
validate_task_creation_response(task_creation_response) validate_task_creation_response(task_creation_response)
task_id = task_creation_response.data.task_id 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) validate_image_result_response(final_response)
images = get_images_from_response(final_response) images = get_images_from_response(final_response)
@ -1528,13 +1646,17 @@ class KlingImageGenerationNode(KlingImageGenerationBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_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." DESCRIPTION = "Kling Image Generation Node. Generate an image from a text prompt with an optional reference image."
def get_response( 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: ) -> KlingImageGenerationsResponse:
return poll_until_finished( return poll_until_finished(
auth_kwargs, auth_kwargs,
@ -1544,6 +1666,9 @@ class KlingImageGenerationNode(KlingImageGenerationBase):
request_model=EmptyRequest, request_model=EmptyRequest,
response_model=KlingImageGenerationsResponse, response_model=KlingImageGenerationsResponse,
), ),
result_url_extractor=get_images_urls_from_response,
estimated_duration=AVERAGE_DURATION_IMAGE_GEN,
node_id=node_id,
) )
def api_call( def api_call(
@ -1557,6 +1682,7 @@ class KlingImageGenerationNode(KlingImageGenerationBase):
n: int, n: int,
aspect_ratio: KlingImageGenAspectRatio, aspect_ratio: KlingImageGenAspectRatio,
image: Optional[torch.Tensor] = None, image: Optional[torch.Tensor] = None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
self.validate_prompt(prompt, negative_prompt) self.validate_prompt(prompt, negative_prompt)
@ -1589,7 +1715,9 @@ class KlingImageGenerationNode(KlingImageGenerationBase):
validate_task_creation_response(task_creation_response) validate_task_creation_response(task_creation_response)
task_id = task_creation_response.data.task_id 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) validate_image_result_response(final_response)
images = get_images_from_response(final_response) images = get_images_from_response(final_response)

View File

@ -36,11 +36,20 @@ from comfy_api_nodes.apinode_utils import (
process_image_response, process_image_response,
validate_string, validate_string,
) )
from server import PromptServer
import requests import requests
import torch import torch
from io import BytesIO 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): class LumaReferenceNode(ComfyNodeABC):
""" """
@ -204,6 +213,7 @@ class LumaImageGenerationNode(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_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, image_luma_ref: LumaReferenceChain = None,
style_image: torch.Tensor = None, style_image: torch.Tensor = None,
character_image: torch.Tensor = None, character_image: torch.Tensor = None,
unique_id: str = None,
**kwargs, **kwargs,
): ):
validate_string(prompt, strip_whitespace=True, min_length=3) validate_string(prompt, strip_whitespace=True, min_length=3)
@ -271,6 +282,8 @@ class LumaImageGenerationNode(ComfyNodeABC):
completed_statuses=[LumaState.completed], completed_statuses=[LumaState.completed],
failed_statuses=[LumaState.failed], 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=unique_id,
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
response_poll = operation.execute() response_poll = operation.execute()
@ -353,6 +366,7 @@ class LumaImageModifyNode(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -363,6 +377,7 @@ class LumaImageModifyNode(ComfyNodeABC):
image: torch.Tensor, image: torch.Tensor,
image_weight: float, image_weight: float,
seed, seed,
unique_id: str = None,
**kwargs, **kwargs,
): ):
# first, upload image # first, upload image
@ -399,6 +414,8 @@ class LumaImageModifyNode(ComfyNodeABC):
completed_statuses=[LumaState.completed], completed_statuses=[LumaState.completed],
failed_statuses=[LumaState.failed], 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=unique_id,
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
response_poll = operation.execute() response_poll = operation.execute()
@ -473,6 +490,7 @@ class LumaTextToVideoGenerationNode(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -486,6 +504,7 @@ class LumaTextToVideoGenerationNode(ComfyNodeABC):
loop: bool, loop: bool,
seed, seed,
luma_concepts: LumaConceptChain = None, luma_concepts: LumaConceptChain = None,
unique_id: str = None,
**kwargs, **kwargs,
): ):
validate_string(prompt, strip_whitespace=False, min_length=3) validate_string(prompt, strip_whitespace=False, min_length=3)
@ -512,6 +531,9 @@ class LumaTextToVideoGenerationNode(ComfyNodeABC):
) )
response_api: LumaGeneration = operation.execute() 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( operation = PollingOperation(
poll_endpoint=ApiEndpoint( poll_endpoint=ApiEndpoint(
path=f"/proxy/luma/generations/{response_api.id}", path=f"/proxy/luma/generations/{response_api.id}",
@ -522,6 +544,9 @@ class LumaTextToVideoGenerationNode(ComfyNodeABC):
completed_statuses=[LumaState.completed], completed_statuses=[LumaState.completed],
failed_statuses=[LumaState.failed], 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=unique_id,
estimated_duration=LUMA_T2V_AVERAGE_DURATION,
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
response_poll = operation.execute() response_poll = operation.execute()
@ -597,6 +622,7 @@ class LumaImageToVideoGenerationNode(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_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, first_image: torch.Tensor = None,
last_image: torch.Tensor = None, last_image: torch.Tensor = None,
luma_concepts: LumaConceptChain = None, luma_concepts: LumaConceptChain = None,
unique_id: str = None,
**kwargs, **kwargs,
): ):
if first_image is None and last_image is None: if first_image is None and last_image is None:
@ -642,6 +669,9 @@ class LumaImageToVideoGenerationNode(ComfyNodeABC):
) )
response_api: LumaGeneration = operation.execute() 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( operation = PollingOperation(
poll_endpoint=ApiEndpoint( poll_endpoint=ApiEndpoint(
path=f"/proxy/luma/generations/{response_api.id}", path=f"/proxy/luma/generations/{response_api.id}",
@ -652,6 +682,9 @@ class LumaImageToVideoGenerationNode(ComfyNodeABC):
completed_statuses=[LumaState.completed], completed_statuses=[LumaState.completed],
failed_statuses=[LumaState.failed], 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=unique_id,
estimated_duration=LUMA_I2V_AVERAGE_DURATION,
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
response_poll = operation.execute() response_poll = operation.execute()

View File

@ -1,3 +1,7 @@
from typing import Union
import logging
import torch
from comfy.comfy_types.node_typing import IO from comfy.comfy_types.node_typing import IO
from comfy_api.input_impl.video_types import VideoFromFile from comfy_api.input_impl.video_types import VideoFromFile
from comfy_api_nodes.apis import ( from comfy_api_nodes.apis import (
@ -20,16 +24,19 @@ from comfy_api_nodes.apinode_utils import (
upload_images_to_comfyapi, upload_images_to_comfyapi,
validate_string, validate_string,
) )
from server import PromptServer
import torch
import logging
I2V_AVERAGE_DURATION = 114
T2V_AVERAGE_DURATION = 234
class MinimaxTextToVideoNode: class MinimaxTextToVideoNode:
""" """
Generates videos synchronously based on a prompt, and optional parameters using MiniMax's API. Generates videos synchronously based on a prompt, and optional parameters using MiniMax's API.
""" """
AVERAGE_DURATION = T2V_AVERAGE_DURATION
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(s):
return { return {
@ -68,6 +75,7 @@ class MinimaxTextToVideoNode:
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -85,6 +93,7 @@ class MinimaxTextToVideoNode:
model="T2V-01", model="T2V-01",
image: torch.Tensor=None, # used for ImageToVideo image: torch.Tensor=None, # used for ImageToVideo
subject: torch.Tensor=None, # used for SubjectToVideo subject: torch.Tensor=None, # used for SubjectToVideo
unique_id: Union[str, None]=None,
**kwargs, **kwargs,
): ):
''' '''
@ -138,6 +147,8 @@ class MinimaxTextToVideoNode:
completed_statuses=["Success"], completed_statuses=["Success"],
failed_statuses=["Fail"], failed_statuses=["Fail"],
status_extractor=lambda x: x.status.value, status_extractor=lambda x: x.status.value,
estimated_duration=self.AVERAGE_DURATION,
node_id=unique_id,
auth_kwargs=kwargs, auth_kwargs=kwargs,
) )
task_result = video_generate_operation.execute() 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()}" f"No video was found in the response. Full response: {file_result.model_dump()}"
) )
logging.info(f"Generated video URL: {file_url}") 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) video_io = download_url_to_bytesio(file_url)
if video_io is None: 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. Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.
""" """
AVERAGE_DURATION = I2V_AVERAGE_DURATION
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(s):
return { return {
@ -223,6 +242,7 @@ class MinimaxImageToVideoNode(MinimaxTextToVideoNode):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_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. Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.
""" """
AVERAGE_DURATION = T2V_AVERAGE_DURATION
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(s):
return { return {
@ -282,6 +304,7 @@ class MinimaxSubjectToVideoNode(MinimaxTextToVideoNode):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }

View File

@ -96,6 +96,7 @@ class OpenAIDalle2(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -113,6 +114,7 @@ class OpenAIDalle2(ComfyNodeABC):
mask=None, mask=None,
n=1, n=1,
size="1024x1024", size="1024x1024",
unique_id=None,
**kwargs **kwargs
): ):
validate_string(prompt, strip_whitespace=False) validate_string(prompt, strip_whitespace=False)
@ -176,7 +178,7 @@ class OpenAIDalle2(ComfyNodeABC):
response = operation.execute() response = operation.execute()
img_tensor = validate_and_cast_response(response) img_tensor = validate_and_cast_response(response, node_id=unique_id)
return (img_tensor,) return (img_tensor,)
@ -242,6 +244,7 @@ class OpenAIDalle3(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -258,6 +261,7 @@ class OpenAIDalle3(ComfyNodeABC):
style="natural", style="natural",
quality="standard", quality="standard",
size="1024x1024", size="1024x1024",
unique_id=None,
**kwargs **kwargs
): ):
validate_string(prompt, strip_whitespace=False) validate_string(prompt, strip_whitespace=False)
@ -284,7 +288,7 @@ class OpenAIDalle3(ComfyNodeABC):
response = operation.execute() response = operation.execute()
img_tensor = validate_and_cast_response(response) img_tensor = validate_and_cast_response(response, node_id=unique_id)
return (img_tensor,) return (img_tensor,)
@ -375,6 +379,7 @@ class OpenAIGPTImage1(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -394,6 +399,7 @@ class OpenAIGPTImage1(ComfyNodeABC):
mask=None, mask=None,
n=1, n=1,
size="1024x1024", size="1024x1024",
unique_id=None,
**kwargs **kwargs
): ):
validate_string(prompt, strip_whitespace=False) validate_string(prompt, strip_whitespace=False)
@ -476,7 +482,7 @@ class OpenAIGPTImage1(ComfyNodeABC):
response = operation.execute() response = operation.execute()
img_tensor = validate_and_cast_response(response) img_tensor = validate_and_cast_response(response, node_id=unique_id)
return (img_tensor,) return (img_tensor,)

View File

@ -121,7 +121,10 @@ class PikaNodeBase(ComfyNodeABC):
RETURN_TYPES = ("VIDEO",) RETURN_TYPES = ("VIDEO",)
def poll_for_task_status( 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: ) -> PikaGenerateResponse:
polling_operation = PollingOperation( polling_operation = PollingOperation(
poll_endpoint=ApiEndpoint( poll_endpoint=ApiEndpoint(
@ -141,13 +144,19 @@ class PikaNodeBase(ComfyNodeABC):
response.progress if hasattr(response, "progress") else None response.progress if hasattr(response, "progress") else None
), ),
auth_kwargs=auth_kwargs, 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() return polling_operation.execute()
def execute_task( def execute_task(
self, self,
initial_operation: SynchronousOperation[R, PikaGenerateResponse], 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]: ) -> tuple[VideoFromFile]:
"""Executes the initial operation then polls for the task status until it is completed. """Executes the initial operation then polls for the task status until it is completed.
@ -208,7 +217,8 @@ class PikaImageToVideoV2_2(PikaNodeBase):
seed: int, seed: int,
resolution: str, resolution: str,
duration: int, duration: int,
**kwargs unique_id: str,
**kwargs,
) -> tuple[VideoFromFile]: ) -> tuple[VideoFromFile]:
# Convert image to BytesIO # Convert image to BytesIO
image_bytes_io = tensor_to_bytesio(image) image_bytes_io = tensor_to_bytesio(image)
@ -238,7 +248,7 @@ class PikaImageToVideoV2_2(PikaNodeBase):
auth_kwargs=kwargs, 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): class PikaTextToVideoNodeV2_2(PikaNodeBase):
@ -262,6 +272,7 @@ class PikaTextToVideoNodeV2_2(PikaNodeBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -275,6 +286,7 @@ class PikaTextToVideoNodeV2_2(PikaNodeBase):
resolution: str, resolution: str,
duration: int, duration: int,
aspect_ratio: float, aspect_ratio: float,
unique_id: str,
**kwargs, **kwargs,
) -> tuple[VideoFromFile]: ) -> tuple[VideoFromFile]:
initial_operation = SynchronousOperation( initial_operation = SynchronousOperation(
@ -296,7 +308,7 @@ class PikaTextToVideoNodeV2_2(PikaNodeBase):
content_type="application/x-www-form-urlencoded", 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): class PikaScenesV2_2(PikaNodeBase):
@ -340,6 +352,7 @@ class PikaScenesV2_2(PikaNodeBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -354,6 +367,7 @@ class PikaScenesV2_2(PikaNodeBase):
duration: int, duration: int,
ingredients_mode: str, ingredients_mode: str,
aspect_ratio: float, aspect_ratio: float,
unique_id: str,
image_ingredient_1: Optional[torch.Tensor] = None, image_ingredient_1: Optional[torch.Tensor] = None,
image_ingredient_2: Optional[torch.Tensor] = None, image_ingredient_2: Optional[torch.Tensor] = None,
image_ingredient_3: Optional[torch.Tensor] = None, image_ingredient_3: Optional[torch.Tensor] = None,
@ -403,7 +417,7 @@ class PikaScenesV2_2(PikaNodeBase):
auth_kwargs=kwargs, 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): class PikAdditionsNode(PikaNodeBase):
@ -439,6 +453,7 @@ class PikAdditionsNode(PikaNodeBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -451,6 +466,7 @@ class PikAdditionsNode(PikaNodeBase):
prompt_text: str, prompt_text: str,
negative_prompt: str, negative_prompt: str,
seed: int, seed: int,
unique_id: str,
**kwargs, **kwargs,
) -> tuple[VideoFromFile]: ) -> tuple[VideoFromFile]:
# Convert video to BytesIO # Convert video to BytesIO
@ -487,7 +503,7 @@ class PikAdditionsNode(PikaNodeBase):
auth_kwargs=kwargs, 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): class PikaSwapsNode(PikaNodeBase):
@ -532,6 +548,7 @@ class PikaSwapsNode(PikaNodeBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -546,6 +563,7 @@ class PikaSwapsNode(PikaNodeBase):
prompt_text: str, prompt_text: str,
negative_prompt: str, negative_prompt: str,
seed: int, seed: int,
unique_id: str,
**kwargs, **kwargs,
) -> tuple[VideoFromFile]: ) -> tuple[VideoFromFile]:
# Convert video to BytesIO # Convert video to BytesIO
@ -592,7 +610,7 @@ class PikaSwapsNode(PikaNodeBase):
auth_kwargs=kwargs, 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): class PikaffectsNode(PikaNodeBase):
@ -637,6 +655,7 @@ class PikaffectsNode(PikaNodeBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -649,6 +668,7 @@ class PikaffectsNode(PikaNodeBase):
prompt_text: str, prompt_text: str,
negative_prompt: str, negative_prompt: str,
seed: int, seed: int,
unique_id: str,
**kwargs, **kwargs,
) -> tuple[VideoFromFile]: ) -> tuple[VideoFromFile]:
@ -670,7 +690,7 @@ class PikaffectsNode(PikaNodeBase):
auth_kwargs=kwargs, 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): class PikaStartEndFrameNode2_2(PikaNodeBase):
@ -689,6 +709,7 @@ class PikaStartEndFrameNode2_2(PikaNodeBase):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -703,6 +724,7 @@ class PikaStartEndFrameNode2_2(PikaNodeBase):
seed: int, seed: int,
resolution: str, resolution: str,
duration: int, duration: int,
unique_id: str,
**kwargs, **kwargs,
) -> tuple[VideoFromFile]: ) -> tuple[VideoFromFile]:
@ -733,7 +755,7 @@ class PikaStartEndFrameNode2_2(PikaNodeBase):
auth_kwargs=kwargs, 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 = { NODE_CLASS_MAPPINGS = {

View File

@ -1,5 +1,5 @@
from inspect import cleandoc from inspect import cleandoc
from typing import Optional
from comfy_api_nodes.apis.pixverse_api import ( from comfy_api_nodes.apis.pixverse_api import (
PixverseTextVideoRequest, PixverseTextVideoRequest,
PixverseImageVideoRequest, PixverseImageVideoRequest,
@ -34,11 +34,22 @@ import requests
from io import BytesIO 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): 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 # first, upload image to Pixverse and get image id to use in actual generation call
files = { files = {"image": tensor_to_bytesio(image)}
"image": tensor_to_bytesio(image)
}
operation = SynchronousOperation( operation = SynchronousOperation(
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
path="/proxy/pixverse/image/upload", 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() response_upload: PixverseImageUploadResponse = operation.execute()
if response_upload.Resp is None: 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 return response_upload.Resp.img_id
@ -73,7 +86,7 @@ class PixverseTemplateNode:
def INPUT_TYPES(s): def INPUT_TYPES(s):
return { return {
"required": { "required": {
"template": (list(pixverse_templates.keys()), ), "template": (list(pixverse_templates.keys()),),
} }
} }
@ -87,7 +100,7 @@ class PixverseTemplateNode:
class PixverseTextToVideoNode(ComfyNodeABC): class PixverseTextToVideoNode(ComfyNodeABC):
""" """
Generates videos synchronously based on prompt and output_size. Generates videos based on prompt and output_size.
""" """
RETURN_TYPES = (IO.VIDEO,) RETURN_TYPES = (IO.VIDEO,)
@ -108,9 +121,7 @@ class PixverseTextToVideoNode(ComfyNodeABC):
"tooltip": "Prompt for the video generation", "tooltip": "Prompt for the video generation",
}, },
), ),
"aspect_ratio": ( "aspect_ratio": ([ratio.value for ratio in PixverseAspectRatio],),
[ratio.value for ratio in PixverseAspectRatio],
),
"quality": ( "quality": (
[resolution.value for resolution in PixverseQuality], [resolution.value for resolution in PixverseQuality],
{ {
@ -143,12 +154,13 @@ class PixverseTextToVideoNode(ComfyNodeABC):
PixverseIO.TEMPLATE, PixverseIO.TEMPLATE,
{ {
"tooltip": "An optional template to influence style of generation, created by the PixVerse Template node." "tooltip": "An optional template to influence style of generation, created by the PixVerse Template node."
} },
) ),
}, },
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -160,8 +172,9 @@ class PixverseTextToVideoNode(ComfyNodeABC):
duration_seconds: int, duration_seconds: int,
motion_mode: str, motion_mode: str,
seed, seed,
negative_prompt: str=None, negative_prompt: str = None,
pixverse_template: int=None, pixverse_template: int = None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
validate_string(prompt, strip_whitespace=False) validate_string(prompt, strip_whitespace=False)
@ -205,19 +218,27 @@ class PixverseTextToVideoNode(ComfyNodeABC):
response_model=PixverseGenerationStatusResponse, response_model=PixverseGenerationStatusResponse,
), ),
completed_statuses=[PixverseStatus.successful], 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, status_extractor=lambda x: x.Resp.status,
auth_kwargs=kwargs, auth_kwargs=kwargs,
node_id=unique_id,
result_url_extractor=get_video_url_from_response,
estimated_duration=AVERAGE_DURATION_T2V,
) )
response_poll = operation.execute() response_poll = operation.execute()
vid_response = requests.get(response_poll.Resp.url) vid_response = requests.get(response_poll.Resp.url)
return (VideoFromFile(BytesIO(vid_response.content)),) return (VideoFromFile(BytesIO(vid_response.content)),)
class PixverseImageToVideoNode(ComfyNodeABC): class PixverseImageToVideoNode(ComfyNodeABC):
""" """
Generates videos synchronously based on prompt and output_size. Generates videos based on prompt and output_size.
""" """
RETURN_TYPES = (IO.VIDEO,) RETURN_TYPES = (IO.VIDEO,)
@ -230,9 +251,7 @@ class PixverseImageToVideoNode(ComfyNodeABC):
def INPUT_TYPES(s): def INPUT_TYPES(s):
return { return {
"required": { "required": {
"image": ( "image": (IO.IMAGE,),
IO.IMAGE,
),
"prompt": ( "prompt": (
IO.STRING, IO.STRING,
{ {
@ -273,12 +292,13 @@ class PixverseImageToVideoNode(ComfyNodeABC):
PixverseIO.TEMPLATE, PixverseIO.TEMPLATE,
{ {
"tooltip": "An optional template to influence style of generation, created by the PixVerse Template node." "tooltip": "An optional template to influence style of generation, created by the PixVerse Template node."
} },
) ),
}, },
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -290,8 +310,9 @@ class PixverseImageToVideoNode(ComfyNodeABC):
duration_seconds: int, duration_seconds: int,
motion_mode: str, motion_mode: str,
seed, seed,
negative_prompt: str=None, negative_prompt: str = None,
pixverse_template: int=None, pixverse_template: int = None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
validate_string(prompt, strip_whitespace=False) validate_string(prompt, strip_whitespace=False)
@ -337,9 +358,16 @@ class PixverseImageToVideoNode(ComfyNodeABC):
response_model=PixverseGenerationStatusResponse, response_model=PixverseGenerationStatusResponse,
), ),
completed_statuses=[PixverseStatus.successful], 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, status_extractor=lambda x: x.Resp.status,
auth_kwargs=kwargs, auth_kwargs=kwargs,
node_id=unique_id,
result_url_extractor=get_video_url_from_response,
estimated_duration=AVERAGE_DURATION_I2V,
) )
response_poll = operation.execute() response_poll = operation.execute()
@ -349,7 +377,7 @@ class PixverseImageToVideoNode(ComfyNodeABC):
class PixverseTransitionVideoNode(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,) RETURN_TYPES = (IO.VIDEO,)
@ -362,12 +390,8 @@ class PixverseTransitionVideoNode(ComfyNodeABC):
def INPUT_TYPES(s): def INPUT_TYPES(s):
return { return {
"required": { "required": {
"first_frame": ( "first_frame": (IO.IMAGE,),
IO.IMAGE, "last_frame": (IO.IMAGE,),
),
"last_frame": (
IO.IMAGE,
),
"prompt": ( "prompt": (
IO.STRING, IO.STRING,
{ {
@ -408,6 +432,7 @@ class PixverseTransitionVideoNode(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -420,7 +445,8 @@ class PixverseTransitionVideoNode(ComfyNodeABC):
duration_seconds: int, duration_seconds: int,
motion_mode: str, motion_mode: str,
seed, seed,
negative_prompt: str=None, negative_prompt: str = None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
validate_string(prompt, strip_whitespace=False) validate_string(prompt, strip_whitespace=False)
@ -467,9 +493,16 @@ class PixverseTransitionVideoNode(ComfyNodeABC):
response_model=PixverseGenerationStatusResponse, response_model=PixverseGenerationStatusResponse,
), ),
completed_statuses=[PixverseStatus.successful], 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, status_extractor=lambda x: x.Resp.status,
auth_kwargs=kwargs, auth_kwargs=kwargs,
node_id=unique_id,
result_url_extractor=get_video_url_from_response,
estimated_duration=AVERAGE_DURATION_T2V,
) )
response_poll = operation.execute() response_poll = operation.execute()

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from inspect import cleandoc from inspect import cleandoc
from typing import Optional
from comfy.utils import ProgressBar from comfy.utils import ProgressBar
from comfy_extras.nodes_images import SVG # Added from comfy_extras.nodes_images import SVG # Added
from comfy.comfy_types.node_typing import IO from comfy.comfy_types.node_typing import IO
@ -29,6 +30,8 @@ from comfy_api_nodes.apinode_utils import (
resize_mask_to_image, resize_mask_to_image,
validate_string, validate_string,
) )
from server import PromptServer
import torch import torch
from io import BytesIO from io import BytesIO
from PIL import UnidentifiedImageError from PIL import UnidentifiedImageError
@ -388,6 +391,7 @@ class RecraftTextToImageNode:
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -400,6 +404,7 @@ class RecraftTextToImageNode:
recraft_style: RecraftStyle = None, recraft_style: RecraftStyle = None,
negative_prompt: str = None, negative_prompt: str = None,
recraft_controls: RecraftControls = None, recraft_controls: RecraftControls = None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
validate_string(prompt, strip_whitespace=False, max_length=1000) validate_string(prompt, strip_whitespace=False, max_length=1000)
@ -436,8 +441,15 @@ class RecraftTextToImageNode:
) )
response: RecraftImageGenerationResponse = operation.execute() response: RecraftImageGenerationResponse = operation.execute()
images = [] images = []
urls = []
for data in response.data: for data in response.data:
with handle_recraft_image_output(): 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( image = bytesio_to_image_tensor(
download_url_to_bytesio(data.url, timeout=1024) download_url_to_bytesio(data.url, timeout=1024)
) )
@ -763,6 +775,7 @@ class RecraftTextToVectorNode:
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -775,6 +788,7 @@ class RecraftTextToVectorNode:
seed, seed,
negative_prompt: str = None, negative_prompt: str = None,
recraft_controls: RecraftControls = None, recraft_controls: RecraftControls = None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
validate_string(prompt, strip_whitespace=False, max_length=1000) validate_string(prompt, strip_whitespace=False, max_length=1000)
@ -809,7 +823,14 @@ class RecraftTextToVectorNode:
) )
response: RecraftImageGenerationResponse = operation.execute() response: RecraftImageGenerationResponse = operation.execute()
svg_data = [] svg_data = []
urls = []
for data in response.data: 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)) svg_data.append(download_url_to_bytesio(data.url, timeout=1024))
return (SVG(svg_data),) return (SVG(svg_data),)

View File

@ -3,6 +3,7 @@ import logging
import base64 import base64
import requests import requests
import torch import torch
from typing import Optional
from comfy.comfy_types.node_typing import IO, ComfyNodeABC from comfy.comfy_types.node_typing import IO, ComfyNodeABC
from comfy_api.input_impl.video_types import VideoFromFile from comfy_api.input_impl.video_types import VideoFromFile
@ -24,6 +25,8 @@ from comfy_api_nodes.apinode_utils import (
tensor_to_base64_string tensor_to_base64_string
) )
AVERAGE_DURATION_VIDEO_GEN = 32
def convert_image_to_base64(image: torch.Tensor): def convert_image_to_base64(image: torch.Tensor):
if image is None: if image is None:
return 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) scaled_image = downscale_image_tensor(image, total_pixels=2048*2048)
return tensor_to_base64_string(scaled_image) 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): class VeoVideoGenerationNode(ComfyNodeABC):
""" """
Generates videos from text prompts using Google's Veo API. Generates videos from text prompts using Google's Veo API.
@ -115,6 +134,7 @@ class VeoVideoGenerationNode(ComfyNodeABC):
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
"comfy_api_key": "API_KEY_COMFY_ORG", "comfy_api_key": "API_KEY_COMFY_ORG",
"unique_id": "UNIQUE_ID",
}, },
} }
@ -134,6 +154,7 @@ class VeoVideoGenerationNode(ComfyNodeABC):
person_generation="ALLOW", person_generation="ALLOW",
seed=0, seed=0,
image=None, image=None,
unique_id: Optional[str] = None,
**kwargs, **kwargs,
): ):
# Prepare the instances for the request # Prepare the instances for the request
@ -215,7 +236,10 @@ class VeoVideoGenerationNode(ComfyNodeABC):
operationName=operation_name operationName=operation_name
), ),
auth_kwargs=kwargs, 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 # Execute the polling operation