Delete comfy_api_nodes
Browse files- comfy_api_nodes/README.md +0 -65
- comfy_api_nodes/__init__.py +0 -0
- comfy_api_nodes/apinode_utils.py +0 -678
- comfy_api_nodes/apis/PixverseController.py +0 -17
- comfy_api_nodes/apis/PixverseDto.py +0 -57
- comfy_api_nodes/apis/__init__.py +0 -0
- comfy_api_nodes/apis/bfl_api.py +0 -174
- comfy_api_nodes/apis/client.py +0 -1126
- comfy_api_nodes/apis/luma_api.py +0 -253
- comfy_api_nodes/apis/pixverse_api.py +0 -146
- comfy_api_nodes/apis/recraft_api.py +0 -262
- comfy_api_nodes/apis/request_logger.py +0 -125
- comfy_api_nodes/apis/rodin_api.py +0 -57
- comfy_api_nodes/apis/stability_api.py +0 -127
- comfy_api_nodes/apis/tripo_api.py +0 -275
- comfy_api_nodes/canary.py +0 -10
- comfy_api_nodes/mapper_utils.py +0 -116
- comfy_api_nodes/nodes_bfl.py +0 -1075
- comfy_api_nodes/nodes_gemini.py +0 -446
- comfy_api_nodes/nodes_ideogram.py +0 -801
- comfy_api_nodes/nodes_kling.py +0 -1760
- comfy_api_nodes/nodes_luma.py +0 -737
- comfy_api_nodes/nodes_minimax.py +0 -332
- comfy_api_nodes/nodes_moonvalley.py +0 -639
- comfy_api_nodes/nodes_openai.py +0 -1008
- comfy_api_nodes/nodes_pika.py +0 -782
- comfy_api_nodes/nodes_pixverse.py +0 -525
- comfy_api_nodes/nodes_recraft.py +0 -1138
- comfy_api_nodes/nodes_rodin.py +0 -462
- comfy_api_nodes/nodes_runway.py +0 -635
- comfy_api_nodes/nodes_stability.py +0 -614
- comfy_api_nodes/nodes_tripo.py +0 -574
- comfy_api_nodes/nodes_veo2.py +0 -308
- comfy_api_nodes/redocly-dev.yaml +0 -10
- comfy_api_nodes/redocly.yaml +0 -10
- comfy_api_nodes/util/__init__.py +0 -0
- comfy_api_nodes/util/validation_utils.py +0 -100
comfy_api_nodes/README.md
DELETED
|
@@ -1,65 +0,0 @@
|
|
| 1 |
-
# ComfyUI API Nodes
|
| 2 |
-
|
| 3 |
-
## Introduction
|
| 4 |
-
|
| 5 |
-
Below are a collection of nodes that work by calling external APIs. More information available in our [docs](https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes).
|
| 6 |
-
|
| 7 |
-
## Development
|
| 8 |
-
|
| 9 |
-
While developing, you should be testing against the Staging environment. To test against staging:
|
| 10 |
-
|
| 11 |
-
**Install ComfyUI_frontend**
|
| 12 |
-
|
| 13 |
-
Follow the instructions [here](https://github.com/Comfy-Org/ComfyUI_frontend) to start the frontend server. By default, it will connect to Staging authentication.
|
| 14 |
-
|
| 15 |
-
> **Hint:** If you use --front-end-version argument for ComfyUI, it will use production authentication.
|
| 16 |
-
|
| 17 |
-
```bash
|
| 18 |
-
python run main.py --comfy-api-base https://stagingapi.comfy.org
|
| 19 |
-
```
|
| 20 |
-
|
| 21 |
-
To authenticate to staging, please login and then ask one of Comfy Org team to whitelist you for access to staging.
|
| 22 |
-
|
| 23 |
-
API stubs are generated through automatic codegen tools from OpenAPI definitions. Since the Comfy Org OpenAPI definition contains many things from the Comfy Registry as well, we use redocly/cli to filter out only the paths relevant for API nodes.
|
| 24 |
-
|
| 25 |
-
### Redocly Instructions
|
| 26 |
-
|
| 27 |
-
**Tip**
|
| 28 |
-
When developing locally, use the `redocly-dev.yaml` file to generate pydantic models. This lets you use stubs for APIs that are not marked `Released` yet.
|
| 29 |
-
|
| 30 |
-
Before your API node PR merges, make sure to add the `Released` tag to the `openapi.yaml` file and test in staging.
|
| 31 |
-
|
| 32 |
-
```bash
|
| 33 |
-
# Download the OpenAPI file from staging server.
|
| 34 |
-
curl -o openapi.yaml https://stagingapi.comfy.org/openapi
|
| 35 |
-
|
| 36 |
-
# Filter out unneeded API definitions.
|
| 37 |
-
npm install -g @redocly/cli
|
| 38 |
-
redocly bundle openapi.yaml --output filtered-openapi.yaml --config comfy_api_nodes/redocly-dev.yaml --remove-unused-components
|
| 39 |
-
|
| 40 |
-
# Generate the pydantic datamodels for validation.
|
| 41 |
-
datamodel-codegen --use-subclass-enum --field-constraints --strict-types bytes --input filtered-openapi.yaml --output comfy_api_nodes/apis/__init__.py --output-model-type pydantic_v2.BaseModel
|
| 42 |
-
|
| 43 |
-
```
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
# Merging to Master
|
| 47 |
-
|
| 48 |
-
Before merging to comfyanonymous/ComfyUI master, follow these steps:
|
| 49 |
-
|
| 50 |
-
1. Add the "Released" tag to the ComfyUI OpenAPI yaml file for each endpoint you are using in the nodes.
|
| 51 |
-
1. Make sure the ComfyUI API is deployed to prod with your changes.
|
| 52 |
-
1. Run the code generation again with `redocly.yaml` and the production OpenAPI yaml file.
|
| 53 |
-
|
| 54 |
-
```bash
|
| 55 |
-
# Download the OpenAPI file from prod server.
|
| 56 |
-
curl -o openapi.yaml https://api.comfy.org/openapi
|
| 57 |
-
|
| 58 |
-
# Filter out unneeded API definitions.
|
| 59 |
-
npm install -g @redocly/cli
|
| 60 |
-
redocly bundle openapi.yaml --output filtered-openapi.yaml --config comfy_api_nodes/redocly.yaml --remove-unused-components
|
| 61 |
-
|
| 62 |
-
# Generate the pydantic datamodels for validation.
|
| 63 |
-
datamodel-codegen --use-subclass-enum --field-constraints --strict-types bytes --input filtered-openapi.yaml --output comfy_api_nodes/apis/__init__.py --output-model-type pydantic_v2.BaseModel
|
| 64 |
-
|
| 65 |
-
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/__init__.py
DELETED
|
File without changes
|
comfy_api_nodes/apinode_utils.py
DELETED
|
@@ -1,678 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
import io
|
| 3 |
-
import logging
|
| 4 |
-
import mimetypes
|
| 5 |
-
from typing import Optional, Union
|
| 6 |
-
from comfy.utils import common_upscale
|
| 7 |
-
from comfy_api.input_impl import VideoFromFile
|
| 8 |
-
from comfy_api.util import VideoContainer, VideoCodec
|
| 9 |
-
from comfy_api.input.video_types import VideoInput
|
| 10 |
-
from comfy_api.input.basic_types import AudioInput
|
| 11 |
-
from comfy_api_nodes.apis.client import (
|
| 12 |
-
ApiClient,
|
| 13 |
-
ApiEndpoint,
|
| 14 |
-
HttpMethod,
|
| 15 |
-
SynchronousOperation,
|
| 16 |
-
UploadRequest,
|
| 17 |
-
UploadResponse,
|
| 18 |
-
)
|
| 19 |
-
from server import PromptServer
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
import numpy as np
|
| 23 |
-
from PIL import Image
|
| 24 |
-
import requests
|
| 25 |
-
import torch
|
| 26 |
-
import math
|
| 27 |
-
import base64
|
| 28 |
-
import uuid
|
| 29 |
-
from io import BytesIO
|
| 30 |
-
import av
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def download_url_to_video_output(video_url: str, timeout: int = None) -> VideoFromFile:
|
| 34 |
-
"""Downloads a video from a URL and returns a `VIDEO` output.
|
| 35 |
-
|
| 36 |
-
Args:
|
| 37 |
-
video_url: The URL of the video to download.
|
| 38 |
-
|
| 39 |
-
Returns:
|
| 40 |
-
A Comfy node `VIDEO` output.
|
| 41 |
-
"""
|
| 42 |
-
video_io = download_url_to_bytesio(video_url, timeout)
|
| 43 |
-
if video_io is None:
|
| 44 |
-
error_msg = f"Failed to download video from {video_url}"
|
| 45 |
-
logging.error(error_msg)
|
| 46 |
-
raise ValueError(error_msg)
|
| 47 |
-
return VideoFromFile(video_io)
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def downscale_image_tensor(image, total_pixels=1536 * 1024) -> torch.Tensor:
|
| 51 |
-
"""Downscale input image tensor to roughly the specified total pixels."""
|
| 52 |
-
samples = image.movedim(-1, 1)
|
| 53 |
-
total = int(total_pixels)
|
| 54 |
-
scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
|
| 55 |
-
if scale_by >= 1:
|
| 56 |
-
return image
|
| 57 |
-
width = round(samples.shape[3] * scale_by)
|
| 58 |
-
height = round(samples.shape[2] * scale_by)
|
| 59 |
-
|
| 60 |
-
s = common_upscale(samples, width, height, "lanczos", "disabled")
|
| 61 |
-
s = s.movedim(1, -1)
|
| 62 |
-
return s
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def validate_and_cast_response(
|
| 66 |
-
response, timeout: int = None, node_id: Union[str, None] = None
|
| 67 |
-
) -> torch.Tensor:
|
| 68 |
-
"""Validates and casts a response to a torch.Tensor.
|
| 69 |
-
|
| 70 |
-
Args:
|
| 71 |
-
response: The response to validate and cast.
|
| 72 |
-
timeout: Request timeout in seconds. Defaults to None (no timeout).
|
| 73 |
-
|
| 74 |
-
Returns:
|
| 75 |
-
A torch.Tensor representing the image (1, H, W, C).
|
| 76 |
-
|
| 77 |
-
Raises:
|
| 78 |
-
ValueError: If the response is not valid.
|
| 79 |
-
"""
|
| 80 |
-
# validate raw JSON response
|
| 81 |
-
data = response.data
|
| 82 |
-
if not data or len(data) == 0:
|
| 83 |
-
raise ValueError("No images returned from API endpoint")
|
| 84 |
-
|
| 85 |
-
# Initialize list to store image tensors
|
| 86 |
-
image_tensors: list[torch.Tensor] = []
|
| 87 |
-
|
| 88 |
-
# Process each image in the data array
|
| 89 |
-
for image_data in data:
|
| 90 |
-
image_url = image_data.url
|
| 91 |
-
b64_data = image_data.b64_json
|
| 92 |
-
|
| 93 |
-
if not image_url and not b64_data:
|
| 94 |
-
raise ValueError("No image was generated in the response")
|
| 95 |
-
|
| 96 |
-
if b64_data:
|
| 97 |
-
img_data = base64.b64decode(b64_data)
|
| 98 |
-
img = Image.open(io.BytesIO(img_data))
|
| 99 |
-
|
| 100 |
-
elif image_url:
|
| 101 |
-
if node_id:
|
| 102 |
-
PromptServer.instance.send_progress_text(
|
| 103 |
-
f"Result URL: {image_url}", node_id
|
| 104 |
-
)
|
| 105 |
-
img_response = requests.get(image_url, timeout=timeout)
|
| 106 |
-
if img_response.status_code != 200:
|
| 107 |
-
raise ValueError("Failed to download the image")
|
| 108 |
-
img = Image.open(io.BytesIO(img_response.content))
|
| 109 |
-
|
| 110 |
-
img = img.convert("RGBA")
|
| 111 |
-
|
| 112 |
-
# Convert to numpy array, normalize to float32 between 0 and 1
|
| 113 |
-
img_array = np.array(img).astype(np.float32) / 255.0
|
| 114 |
-
img_tensor = torch.from_numpy(img_array)
|
| 115 |
-
|
| 116 |
-
# Add to list of tensors
|
| 117 |
-
image_tensors.append(img_tensor)
|
| 118 |
-
|
| 119 |
-
return torch.stack(image_tensors, dim=0)
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def validate_aspect_ratio(
|
| 123 |
-
aspect_ratio: str,
|
| 124 |
-
minimum_ratio: float,
|
| 125 |
-
maximum_ratio: float,
|
| 126 |
-
minimum_ratio_str: str,
|
| 127 |
-
maximum_ratio_str: str,
|
| 128 |
-
) -> float:
|
| 129 |
-
"""Validates and casts an aspect ratio string to a float.
|
| 130 |
-
|
| 131 |
-
Args:
|
| 132 |
-
aspect_ratio: The aspect ratio string to validate.
|
| 133 |
-
minimum_ratio: The minimum aspect ratio.
|
| 134 |
-
maximum_ratio: The maximum aspect ratio.
|
| 135 |
-
minimum_ratio_str: The minimum aspect ratio string.
|
| 136 |
-
maximum_ratio_str: The maximum aspect ratio string.
|
| 137 |
-
|
| 138 |
-
Returns:
|
| 139 |
-
The validated and cast aspect ratio.
|
| 140 |
-
|
| 141 |
-
Raises:
|
| 142 |
-
Exception: If the aspect ratio is not valid.
|
| 143 |
-
"""
|
| 144 |
-
# get ratio values
|
| 145 |
-
numbers = aspect_ratio.split(":")
|
| 146 |
-
if len(numbers) != 2:
|
| 147 |
-
raise TypeError(
|
| 148 |
-
f"Aspect ratio must be in the format X:Y, such as 16:9, but was {aspect_ratio}."
|
| 149 |
-
)
|
| 150 |
-
try:
|
| 151 |
-
numerator = int(numbers[0])
|
| 152 |
-
denominator = int(numbers[1])
|
| 153 |
-
except ValueError as exc:
|
| 154 |
-
raise TypeError(
|
| 155 |
-
f"Aspect ratio must contain numbers separated by ':', such as 16:9, but was {aspect_ratio}."
|
| 156 |
-
) from exc
|
| 157 |
-
calculated_ratio = numerator / denominator
|
| 158 |
-
# if not close to minimum and maximum, check bounds
|
| 159 |
-
if not math.isclose(calculated_ratio, minimum_ratio) or not math.isclose(
|
| 160 |
-
calculated_ratio, maximum_ratio
|
| 161 |
-
):
|
| 162 |
-
if calculated_ratio < minimum_ratio:
|
| 163 |
-
raise TypeError(
|
| 164 |
-
f"Aspect ratio cannot reduce to any less than {minimum_ratio_str} ({minimum_ratio}), but was {aspect_ratio} ({calculated_ratio})."
|
| 165 |
-
)
|
| 166 |
-
elif calculated_ratio > maximum_ratio:
|
| 167 |
-
raise TypeError(
|
| 168 |
-
f"Aspect ratio cannot reduce to any greater than {maximum_ratio_str} ({maximum_ratio}), but was {aspect_ratio} ({calculated_ratio})."
|
| 169 |
-
)
|
| 170 |
-
return aspect_ratio
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def mimetype_to_extension(mime_type: str) -> str:
|
| 174 |
-
"""Converts a MIME type to a file extension."""
|
| 175 |
-
return mime_type.split("/")[-1].lower()
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
def download_url_to_bytesio(url: str, timeout: int = None) -> BytesIO:
|
| 179 |
-
"""Downloads content from a URL using requests and returns it as BytesIO.
|
| 180 |
-
|
| 181 |
-
Args:
|
| 182 |
-
url: The URL to download.
|
| 183 |
-
timeout: Request timeout in seconds. Defaults to None (no timeout).
|
| 184 |
-
|
| 185 |
-
Returns:
|
| 186 |
-
BytesIO object containing the downloaded content.
|
| 187 |
-
"""
|
| 188 |
-
response = requests.get(url, stream=True, timeout=timeout)
|
| 189 |
-
response.raise_for_status() # Raises HTTPError for bad responses (4XX or 5XX)
|
| 190 |
-
return BytesIO(response.content)
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
def bytesio_to_image_tensor(image_bytesio: BytesIO, mode: str = "RGBA") -> torch.Tensor:
|
| 194 |
-
"""Converts image data from BytesIO to a torch.Tensor.
|
| 195 |
-
|
| 196 |
-
Args:
|
| 197 |
-
image_bytesio: BytesIO object containing the image data.
|
| 198 |
-
mode: The PIL mode to convert the image to (e.g., "RGB", "RGBA").
|
| 199 |
-
|
| 200 |
-
Returns:
|
| 201 |
-
A torch.Tensor representing the image (1, H, W, C).
|
| 202 |
-
|
| 203 |
-
Raises:
|
| 204 |
-
PIL.UnidentifiedImageError: If the image data cannot be identified.
|
| 205 |
-
ValueError: If the specified mode is invalid.
|
| 206 |
-
"""
|
| 207 |
-
image = Image.open(image_bytesio)
|
| 208 |
-
image = image.convert(mode)
|
| 209 |
-
image_array = np.array(image).astype(np.float32) / 255.0
|
| 210 |
-
return torch.from_numpy(image_array).unsqueeze(0)
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
def download_url_to_image_tensor(url: str, timeout: int = None) -> torch.Tensor:
|
| 214 |
-
"""Downloads an image from a URL and returns a [B, H, W, C] tensor."""
|
| 215 |
-
image_bytesio = download_url_to_bytesio(url, timeout)
|
| 216 |
-
return bytesio_to_image_tensor(image_bytesio)
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
def process_image_response(response: requests.Response) -> torch.Tensor:
|
| 220 |
-
"""Uses content from a Response object and converts it to a torch.Tensor"""
|
| 221 |
-
return bytesio_to_image_tensor(BytesIO(response.content))
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
def _tensor_to_pil(image: torch.Tensor, total_pixels: int = 2048 * 2048) -> Image.Image:
|
| 225 |
-
"""Converts a single torch.Tensor image [H, W, C] to a PIL Image, optionally downscaling."""
|
| 226 |
-
if len(image.shape) > 3:
|
| 227 |
-
image = image[0]
|
| 228 |
-
# TODO: remove alpha if not allowed and present
|
| 229 |
-
input_tensor = image.cpu()
|
| 230 |
-
input_tensor = downscale_image_tensor(
|
| 231 |
-
input_tensor.unsqueeze(0), total_pixels=total_pixels
|
| 232 |
-
).squeeze()
|
| 233 |
-
image_np = (input_tensor.numpy() * 255).astype(np.uint8)
|
| 234 |
-
img = Image.fromarray(image_np)
|
| 235 |
-
return img
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
def _pil_to_bytesio(img: Image.Image, mime_type: str = "image/png") -> BytesIO:
|
| 239 |
-
"""Converts a PIL Image to a BytesIO object."""
|
| 240 |
-
if not mime_type:
|
| 241 |
-
mime_type = "image/png"
|
| 242 |
-
|
| 243 |
-
img_byte_arr = io.BytesIO()
|
| 244 |
-
# Derive PIL format from MIME type (e.g., 'image/png' -> 'PNG')
|
| 245 |
-
pil_format = mime_type.split("/")[-1].upper()
|
| 246 |
-
if pil_format == "JPG":
|
| 247 |
-
pil_format = "JPEG"
|
| 248 |
-
img.save(img_byte_arr, format=pil_format)
|
| 249 |
-
img_byte_arr.seek(0)
|
| 250 |
-
return img_byte_arr
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
def tensor_to_bytesio(
|
| 254 |
-
image: torch.Tensor,
|
| 255 |
-
name: Optional[str] = None,
|
| 256 |
-
total_pixels: int = 2048 * 2048,
|
| 257 |
-
mime_type: str = "image/png",
|
| 258 |
-
) -> BytesIO:
|
| 259 |
-
"""Converts a torch.Tensor image to a named BytesIO object.
|
| 260 |
-
|
| 261 |
-
Args:
|
| 262 |
-
image: Input torch.Tensor image.
|
| 263 |
-
name: Optional filename for the BytesIO object.
|
| 264 |
-
total_pixels: Maximum total pixels for potential downscaling.
|
| 265 |
-
mime_type: Target image MIME type (e.g., 'image/png', 'image/jpeg', 'image/webp', 'video/mp4').
|
| 266 |
-
|
| 267 |
-
Returns:
|
| 268 |
-
Named BytesIO object containing the image data.
|
| 269 |
-
"""
|
| 270 |
-
if not mime_type:
|
| 271 |
-
mime_type = "image/png"
|
| 272 |
-
|
| 273 |
-
pil_image = _tensor_to_pil(image, total_pixels=total_pixels)
|
| 274 |
-
img_binary = _pil_to_bytesio(pil_image, mime_type=mime_type)
|
| 275 |
-
img_binary.name = (
|
| 276 |
-
f"{name if name else uuid.uuid4()}.{mimetype_to_extension(mime_type)}"
|
| 277 |
-
)
|
| 278 |
-
return img_binary
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
def tensor_to_base64_string(
|
| 282 |
-
image_tensor: torch.Tensor,
|
| 283 |
-
total_pixels: int = 2048 * 2048,
|
| 284 |
-
mime_type: str = "image/png",
|
| 285 |
-
) -> str:
|
| 286 |
-
"""Convert [B, H, W, C] or [H, W, C] tensor to a base64 string.
|
| 287 |
-
|
| 288 |
-
Args:
|
| 289 |
-
image_tensor: Input torch.Tensor image.
|
| 290 |
-
total_pixels: Maximum total pixels for potential downscaling.
|
| 291 |
-
mime_type: Target image MIME type (e.g., 'image/png', 'image/jpeg', 'image/webp', 'video/mp4').
|
| 292 |
-
|
| 293 |
-
Returns:
|
| 294 |
-
Base64 encoded string of the image.
|
| 295 |
-
"""
|
| 296 |
-
pil_image = _tensor_to_pil(image_tensor, total_pixels=total_pixels)
|
| 297 |
-
img_byte_arr = _pil_to_bytesio(pil_image, mime_type=mime_type)
|
| 298 |
-
img_bytes = img_byte_arr.getvalue()
|
| 299 |
-
# Encode bytes to base64 string
|
| 300 |
-
base64_encoded_string = base64.b64encode(img_bytes).decode("utf-8")
|
| 301 |
-
return base64_encoded_string
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
def tensor_to_data_uri(
|
| 305 |
-
image_tensor: torch.Tensor,
|
| 306 |
-
total_pixels: int = 2048 * 2048,
|
| 307 |
-
mime_type: str = "image/png",
|
| 308 |
-
) -> str:
|
| 309 |
-
"""Converts a tensor image to a Data URI string.
|
| 310 |
-
|
| 311 |
-
Args:
|
| 312 |
-
image_tensor: Input torch.Tensor image.
|
| 313 |
-
total_pixels: Maximum total pixels for potential downscaling.
|
| 314 |
-
mime_type: Target image MIME type (e.g., 'image/png', 'image/jpeg', 'image/webp').
|
| 315 |
-
|
| 316 |
-
Returns:
|
| 317 |
-
Data URI string (e.g., 'data:image/png;base64,...').
|
| 318 |
-
"""
|
| 319 |
-
base64_string = tensor_to_base64_string(image_tensor, total_pixels, mime_type)
|
| 320 |
-
return f"data:{mime_type};base64,{base64_string}"
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
def text_filepath_to_base64_string(filepath: str) -> str:
|
| 324 |
-
"""Converts a text file to a base64 string."""
|
| 325 |
-
with open(filepath, "rb") as f:
|
| 326 |
-
file_content = f.read()
|
| 327 |
-
return base64.b64encode(file_content).decode("utf-8")
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
def text_filepath_to_data_uri(filepath: str) -> str:
|
| 331 |
-
"""Converts a text file to a data URI."""
|
| 332 |
-
base64_string = text_filepath_to_base64_string(filepath)
|
| 333 |
-
mime_type, _ = mimetypes.guess_type(filepath)
|
| 334 |
-
if mime_type is None:
|
| 335 |
-
mime_type = "application/octet-stream"
|
| 336 |
-
return f"data:{mime_type};base64,{base64_string}"
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
def upload_file_to_comfyapi(
|
| 340 |
-
file_bytes_io: BytesIO,
|
| 341 |
-
filename: str,
|
| 342 |
-
upload_mime_type: str,
|
| 343 |
-
auth_kwargs: Optional[dict[str, str]] = None,
|
| 344 |
-
) -> str:
|
| 345 |
-
"""
|
| 346 |
-
Uploads a single file to ComfyUI API and returns its download URL.
|
| 347 |
-
|
| 348 |
-
Args:
|
| 349 |
-
file_bytes_io: BytesIO object containing the file data.
|
| 350 |
-
filename: The filename of the file.
|
| 351 |
-
upload_mime_type: MIME type of the file.
|
| 352 |
-
auth_kwargs: Optional authentication token(s).
|
| 353 |
-
|
| 354 |
-
Returns:
|
| 355 |
-
The download URL for the uploaded file.
|
| 356 |
-
"""
|
| 357 |
-
request_object = UploadRequest(file_name=filename, content_type=upload_mime_type)
|
| 358 |
-
operation = SynchronousOperation(
|
| 359 |
-
endpoint=ApiEndpoint(
|
| 360 |
-
path="/customers/storage",
|
| 361 |
-
method=HttpMethod.POST,
|
| 362 |
-
request_model=UploadRequest,
|
| 363 |
-
response_model=UploadResponse,
|
| 364 |
-
),
|
| 365 |
-
request=request_object,
|
| 366 |
-
auth_kwargs=auth_kwargs,
|
| 367 |
-
)
|
| 368 |
-
|
| 369 |
-
response: UploadResponse = operation.execute()
|
| 370 |
-
upload_response = ApiClient.upload_file(
|
| 371 |
-
response.upload_url, file_bytes_io, content_type=upload_mime_type
|
| 372 |
-
)
|
| 373 |
-
upload_response.raise_for_status()
|
| 374 |
-
|
| 375 |
-
return response.download_url
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
def video_to_base64_string(
|
| 379 |
-
video: VideoInput,
|
| 380 |
-
container_format: VideoContainer = None,
|
| 381 |
-
codec: VideoCodec = None
|
| 382 |
-
) -> str:
|
| 383 |
-
"""
|
| 384 |
-
Converts a video input to a base64 string.
|
| 385 |
-
|
| 386 |
-
Args:
|
| 387 |
-
video: The video input to convert
|
| 388 |
-
container_format: Optional container format to use (defaults to video.container if available)
|
| 389 |
-
codec: Optional codec to use (defaults to video.codec if available)
|
| 390 |
-
"""
|
| 391 |
-
video_bytes_io = io.BytesIO()
|
| 392 |
-
|
| 393 |
-
# Use provided format/codec if specified, otherwise use video's own if available
|
| 394 |
-
format_to_use = container_format if container_format is not None else getattr(video, 'container', VideoContainer.MP4)
|
| 395 |
-
codec_to_use = codec if codec is not None else getattr(video, 'codec', VideoCodec.H264)
|
| 396 |
-
|
| 397 |
-
video.save_to(video_bytes_io, format=format_to_use, codec=codec_to_use)
|
| 398 |
-
video_bytes_io.seek(0)
|
| 399 |
-
return base64.b64encode(video_bytes_io.getvalue()).decode("utf-8")
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
def upload_video_to_comfyapi(
|
| 403 |
-
video: VideoInput,
|
| 404 |
-
auth_kwargs: Optional[dict[str, str]] = None,
|
| 405 |
-
container: VideoContainer = VideoContainer.MP4,
|
| 406 |
-
codec: VideoCodec = VideoCodec.H264,
|
| 407 |
-
max_duration: Optional[int] = None,
|
| 408 |
-
) -> str:
|
| 409 |
-
"""
|
| 410 |
-
Uploads a single video to ComfyUI API and returns its download URL.
|
| 411 |
-
Uses the specified container and codec for saving the video before upload.
|
| 412 |
-
|
| 413 |
-
Args:
|
| 414 |
-
video: VideoInput object (Comfy VIDEO type).
|
| 415 |
-
auth_kwargs: Optional authentication token(s).
|
| 416 |
-
container: The video container format to use (default: MP4).
|
| 417 |
-
codec: The video codec to use (default: H264).
|
| 418 |
-
max_duration: Optional maximum duration of the video in seconds. If the video is longer than this, an error will be raised.
|
| 419 |
-
|
| 420 |
-
Returns:
|
| 421 |
-
The download URL for the uploaded video file.
|
| 422 |
-
"""
|
| 423 |
-
if max_duration is not None:
|
| 424 |
-
try:
|
| 425 |
-
actual_duration = video.duration_seconds
|
| 426 |
-
if actual_duration is not None and actual_duration > max_duration:
|
| 427 |
-
raise ValueError(
|
| 428 |
-
f"Video duration ({actual_duration:.2f}s) exceeds the maximum allowed ({max_duration}s)."
|
| 429 |
-
)
|
| 430 |
-
except Exception as e:
|
| 431 |
-
logging.error(f"Error getting video duration: {e}")
|
| 432 |
-
raise ValueError(f"Could not verify video duration from source: {e}") from e
|
| 433 |
-
|
| 434 |
-
upload_mime_type = f"video/{container.value.lower()}"
|
| 435 |
-
filename = f"uploaded_video.{container.value.lower()}"
|
| 436 |
-
|
| 437 |
-
# Convert VideoInput to BytesIO using specified container/codec
|
| 438 |
-
video_bytes_io = io.BytesIO()
|
| 439 |
-
video.save_to(video_bytes_io, format=container, codec=codec)
|
| 440 |
-
video_bytes_io.seek(0)
|
| 441 |
-
|
| 442 |
-
return upload_file_to_comfyapi(
|
| 443 |
-
video_bytes_io, filename, upload_mime_type, auth_kwargs
|
| 444 |
-
)
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
def audio_tensor_to_contiguous_ndarray(waveform: torch.Tensor) -> np.ndarray:
|
| 448 |
-
"""
|
| 449 |
-
Prepares audio waveform for av library by converting to a contiguous numpy array.
|
| 450 |
-
|
| 451 |
-
Args:
|
| 452 |
-
waveform: a tensor of shape (1, channels, samples) derived from a Comfy `AUDIO` type.
|
| 453 |
-
|
| 454 |
-
Returns:
|
| 455 |
-
Contiguous numpy array of the audio waveform. If the audio was batched,
|
| 456 |
-
the first item is taken.
|
| 457 |
-
"""
|
| 458 |
-
if waveform.ndim != 3 or waveform.shape[0] != 1:
|
| 459 |
-
raise ValueError("Expected waveform tensor shape (1, channels, samples)")
|
| 460 |
-
|
| 461 |
-
# If batch is > 1, take first item
|
| 462 |
-
if waveform.shape[0] > 1:
|
| 463 |
-
waveform = waveform[0]
|
| 464 |
-
|
| 465 |
-
# Prepare for av: remove batch dim, move to CPU, make contiguous, convert to numpy array
|
| 466 |
-
audio_data_np = waveform.squeeze(0).cpu().contiguous().numpy()
|
| 467 |
-
if audio_data_np.dtype != np.float32:
|
| 468 |
-
audio_data_np = audio_data_np.astype(np.float32)
|
| 469 |
-
|
| 470 |
-
return audio_data_np
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
def audio_ndarray_to_bytesio(
|
| 474 |
-
audio_data_np: np.ndarray,
|
| 475 |
-
sample_rate: int,
|
| 476 |
-
container_format: str = "mp4",
|
| 477 |
-
codec_name: str = "aac",
|
| 478 |
-
) -> BytesIO:
|
| 479 |
-
"""
|
| 480 |
-
Encodes a numpy array of audio data into a BytesIO object.
|
| 481 |
-
"""
|
| 482 |
-
audio_bytes_io = io.BytesIO()
|
| 483 |
-
with av.open(audio_bytes_io, mode="w", format=container_format) as output_container:
|
| 484 |
-
audio_stream = output_container.add_stream(codec_name, rate=sample_rate)
|
| 485 |
-
frame = av.AudioFrame.from_ndarray(
|
| 486 |
-
audio_data_np,
|
| 487 |
-
format="fltp",
|
| 488 |
-
layout="stereo" if audio_data_np.shape[0] > 1 else "mono",
|
| 489 |
-
)
|
| 490 |
-
frame.sample_rate = sample_rate
|
| 491 |
-
frame.pts = 0
|
| 492 |
-
|
| 493 |
-
for packet in audio_stream.encode(frame):
|
| 494 |
-
output_container.mux(packet)
|
| 495 |
-
|
| 496 |
-
# Flush stream
|
| 497 |
-
for packet in audio_stream.encode(None):
|
| 498 |
-
output_container.mux(packet)
|
| 499 |
-
|
| 500 |
-
audio_bytes_io.seek(0)
|
| 501 |
-
return audio_bytes_io
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
def upload_audio_to_comfyapi(
|
| 505 |
-
audio: AudioInput,
|
| 506 |
-
auth_kwargs: Optional[dict[str, str]] = None,
|
| 507 |
-
container_format: str = "mp4",
|
| 508 |
-
codec_name: str = "aac",
|
| 509 |
-
mime_type: str = "audio/mp4",
|
| 510 |
-
filename: str = "uploaded_audio.mp4",
|
| 511 |
-
) -> str:
|
| 512 |
-
"""
|
| 513 |
-
Uploads a single audio input to ComfyUI API and returns its download URL.
|
| 514 |
-
Encodes the raw waveform into the specified format before uploading.
|
| 515 |
-
|
| 516 |
-
Args:
|
| 517 |
-
audio: a Comfy `AUDIO` type (contains waveform tensor and sample_rate)
|
| 518 |
-
auth_kwargs: Optional authentication token(s).
|
| 519 |
-
|
| 520 |
-
Returns:
|
| 521 |
-
The download URL for the uploaded audio file.
|
| 522 |
-
"""
|
| 523 |
-
sample_rate: int = audio["sample_rate"]
|
| 524 |
-
waveform: torch.Tensor = audio["waveform"]
|
| 525 |
-
audio_data_np = audio_tensor_to_contiguous_ndarray(waveform)
|
| 526 |
-
audio_bytes_io = audio_ndarray_to_bytesio(
|
| 527 |
-
audio_data_np, sample_rate, container_format, codec_name
|
| 528 |
-
)
|
| 529 |
-
|
| 530 |
-
return upload_file_to_comfyapi(audio_bytes_io, filename, mime_type, auth_kwargs)
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
def audio_to_base64_string(
|
| 534 |
-
audio: AudioInput, container_format: str = "mp4", codec_name: str = "aac"
|
| 535 |
-
) -> str:
|
| 536 |
-
"""Converts an audio input to a base64 string."""
|
| 537 |
-
sample_rate: int = audio["sample_rate"]
|
| 538 |
-
waveform: torch.Tensor = audio["waveform"]
|
| 539 |
-
audio_data_np = audio_tensor_to_contiguous_ndarray(waveform)
|
| 540 |
-
audio_bytes_io = audio_ndarray_to_bytesio(
|
| 541 |
-
audio_data_np, sample_rate, container_format, codec_name
|
| 542 |
-
)
|
| 543 |
-
audio_bytes = audio_bytes_io.getvalue()
|
| 544 |
-
return base64.b64encode(audio_bytes).decode("utf-8")
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
def upload_images_to_comfyapi(
|
| 548 |
-
image: torch.Tensor,
|
| 549 |
-
max_images=8,
|
| 550 |
-
auth_kwargs: Optional[dict[str, str]] = None,
|
| 551 |
-
mime_type: Optional[str] = None,
|
| 552 |
-
) -> list[str]:
|
| 553 |
-
"""
|
| 554 |
-
Uploads images to ComfyUI API and returns download URLs.
|
| 555 |
-
To upload multiple images, stack them in the batch dimension first.
|
| 556 |
-
|
| 557 |
-
Args:
|
| 558 |
-
image: Input torch.Tensor image.
|
| 559 |
-
max_images: Maximum number of images to upload.
|
| 560 |
-
auth_kwargs: Optional authentication token(s).
|
| 561 |
-
mime_type: Optional MIME type for the image.
|
| 562 |
-
"""
|
| 563 |
-
# if batch, try to upload each file if max_images is greater than 0
|
| 564 |
-
idx_image = 0
|
| 565 |
-
download_urls: list[str] = []
|
| 566 |
-
is_batch = len(image.shape) > 3
|
| 567 |
-
batch_length = 1
|
| 568 |
-
if is_batch:
|
| 569 |
-
batch_length = image.shape[0]
|
| 570 |
-
while True:
|
| 571 |
-
curr_image = image
|
| 572 |
-
if len(image.shape) > 3:
|
| 573 |
-
curr_image = image[idx_image]
|
| 574 |
-
# get BytesIO version of image
|
| 575 |
-
img_binary = tensor_to_bytesio(curr_image, mime_type=mime_type)
|
| 576 |
-
# first, request upload/download urls from comfy API
|
| 577 |
-
if not mime_type:
|
| 578 |
-
request_object = UploadRequest(file_name=img_binary.name)
|
| 579 |
-
else:
|
| 580 |
-
request_object = UploadRequest(
|
| 581 |
-
file_name=img_binary.name, content_type=mime_type
|
| 582 |
-
)
|
| 583 |
-
operation = SynchronousOperation(
|
| 584 |
-
endpoint=ApiEndpoint(
|
| 585 |
-
path="/customers/storage",
|
| 586 |
-
method=HttpMethod.POST,
|
| 587 |
-
request_model=UploadRequest,
|
| 588 |
-
response_model=UploadResponse,
|
| 589 |
-
),
|
| 590 |
-
request=request_object,
|
| 591 |
-
auth_kwargs=auth_kwargs,
|
| 592 |
-
)
|
| 593 |
-
response = operation.execute()
|
| 594 |
-
|
| 595 |
-
upload_response = ApiClient.upload_file(
|
| 596 |
-
response.upload_url, img_binary, content_type=mime_type
|
| 597 |
-
)
|
| 598 |
-
# verify success
|
| 599 |
-
try:
|
| 600 |
-
upload_response.raise_for_status()
|
| 601 |
-
except requests.exceptions.HTTPError as e:
|
| 602 |
-
raise ValueError(f"Could not upload one or more images: {e}") from e
|
| 603 |
-
# add download_url to list
|
| 604 |
-
download_urls.append(response.download_url)
|
| 605 |
-
|
| 606 |
-
idx_image += 1
|
| 607 |
-
# stop uploading additional files if done
|
| 608 |
-
if is_batch and max_images > 0:
|
| 609 |
-
if idx_image >= max_images:
|
| 610 |
-
break
|
| 611 |
-
if idx_image >= batch_length:
|
| 612 |
-
break
|
| 613 |
-
return download_urls
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
def resize_mask_to_image(
|
| 617 |
-
mask: torch.Tensor,
|
| 618 |
-
image: torch.Tensor,
|
| 619 |
-
upscale_method="nearest-exact",
|
| 620 |
-
crop="disabled",
|
| 621 |
-
allow_gradient=True,
|
| 622 |
-
add_channel_dim=False,
|
| 623 |
-
):
|
| 624 |
-
"""
|
| 625 |
-
Resize mask to be the same dimensions as an image, while maintaining proper format for API calls.
|
| 626 |
-
"""
|
| 627 |
-
_, H, W, _ = image.shape
|
| 628 |
-
mask = mask.unsqueeze(-1)
|
| 629 |
-
mask = mask.movedim(-1, 1)
|
| 630 |
-
mask = common_upscale(
|
| 631 |
-
mask, width=W, height=H, upscale_method=upscale_method, crop=crop
|
| 632 |
-
)
|
| 633 |
-
mask = mask.movedim(1, -1)
|
| 634 |
-
if not add_channel_dim:
|
| 635 |
-
mask = mask.squeeze(-1)
|
| 636 |
-
if not allow_gradient:
|
| 637 |
-
mask = (mask > 0.5).float()
|
| 638 |
-
return mask
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
def validate_string(
|
| 642 |
-
string: str,
|
| 643 |
-
strip_whitespace=True,
|
| 644 |
-
field_name="prompt",
|
| 645 |
-
min_length=None,
|
| 646 |
-
max_length=None,
|
| 647 |
-
):
|
| 648 |
-
if string is None:
|
| 649 |
-
raise Exception(f"Field '{field_name}' cannot be empty.")
|
| 650 |
-
if strip_whitespace:
|
| 651 |
-
string = string.strip()
|
| 652 |
-
if min_length and len(string) < min_length:
|
| 653 |
-
raise Exception(
|
| 654 |
-
f"Field '{field_name}' cannot be shorter than {min_length} characters; was {len(string)} characters long."
|
| 655 |
-
)
|
| 656 |
-
if max_length and len(string) > max_length:
|
| 657 |
-
raise Exception(
|
| 658 |
-
f" Field '{field_name} cannot be longer than {max_length} characters; was {len(string)} characters long."
|
| 659 |
-
)
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
def image_tensor_pair_to_batch(
|
| 663 |
-
image1: torch.Tensor, image2: torch.Tensor
|
| 664 |
-
) -> torch.Tensor:
|
| 665 |
-
"""
|
| 666 |
-
Converts a pair of image tensors to a batch tensor.
|
| 667 |
-
If the images are not the same size, the smaller image is resized to
|
| 668 |
-
match the larger image.
|
| 669 |
-
"""
|
| 670 |
-
if image1.shape[1:] != image2.shape[1:]:
|
| 671 |
-
image2 = common_upscale(
|
| 672 |
-
image2.movedim(-1, 1),
|
| 673 |
-
image1.shape[2],
|
| 674 |
-
image1.shape[1],
|
| 675 |
-
"bilinear",
|
| 676 |
-
"center",
|
| 677 |
-
).movedim(1, -1)
|
| 678 |
-
return torch.cat((image1, image2), dim=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/PixverseController.py
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
# generated by datamodel-codegen:
|
| 2 |
-
# filename: filtered-openapi.yaml
|
| 3 |
-
# timestamp: 2025-04-29T23:44:54+00:00
|
| 4 |
-
|
| 5 |
-
from __future__ import annotations
|
| 6 |
-
|
| 7 |
-
from typing import Optional
|
| 8 |
-
|
| 9 |
-
from pydantic import BaseModel
|
| 10 |
-
|
| 11 |
-
from . import PixverseDto
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class ResponseData(BaseModel):
|
| 15 |
-
ErrCode: Optional[int] = None
|
| 16 |
-
ErrMsg: Optional[str] = None
|
| 17 |
-
Resp: Optional[PixverseDto.V2OpenAPII2VResp] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/PixverseDto.py
DELETED
|
@@ -1,57 +0,0 @@
|
|
| 1 |
-
# generated by datamodel-codegen:
|
| 2 |
-
# filename: filtered-openapi.yaml
|
| 3 |
-
# timestamp: 2025-04-29T23:44:54+00:00
|
| 4 |
-
|
| 5 |
-
from __future__ import annotations
|
| 6 |
-
|
| 7 |
-
from typing import Optional
|
| 8 |
-
|
| 9 |
-
from pydantic import BaseModel, Field
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class V2OpenAPII2VResp(BaseModel):
|
| 13 |
-
video_id: Optional[int] = Field(None, description='Video_id')
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
class V2OpenAPIT2VReq(BaseModel):
|
| 17 |
-
aspect_ratio: str = Field(
|
| 18 |
-
..., description='Aspect ratio (16:9, 4:3, 1:1, 3:4, 9:16)', examples=['16:9']
|
| 19 |
-
)
|
| 20 |
-
duration: int = Field(
|
| 21 |
-
...,
|
| 22 |
-
description='Video duration (5, 8 seconds, --model=v3.5 only allows 5,8; --quality=1080p does not support 8s)',
|
| 23 |
-
examples=[5],
|
| 24 |
-
)
|
| 25 |
-
model: str = Field(
|
| 26 |
-
..., description='Model version (only supports v3.5)', examples=['v3.5']
|
| 27 |
-
)
|
| 28 |
-
motion_mode: Optional[str] = Field(
|
| 29 |
-
'normal',
|
| 30 |
-
description='Motion mode (normal, fast, --fast only available when duration=5; --quality=1080p does not support fast)',
|
| 31 |
-
examples=['normal'],
|
| 32 |
-
)
|
| 33 |
-
negative_prompt: Optional[str] = Field(
|
| 34 |
-
None, description='Negative prompt\n', max_length=2048
|
| 35 |
-
)
|
| 36 |
-
prompt: str = Field(..., description='Prompt', max_length=2048)
|
| 37 |
-
quality: str = Field(
|
| 38 |
-
...,
|
| 39 |
-
description='Video quality ("360p"(Turbo model), "540p", "720p", "1080p")',
|
| 40 |
-
examples=['540p'],
|
| 41 |
-
)
|
| 42 |
-
seed: Optional[int] = Field(None, description='Random seed, range: 0 - 2147483647')
|
| 43 |
-
style: Optional[str] = Field(
|
| 44 |
-
None,
|
| 45 |
-
description='Style (effective when model=v3.5, "anime", "3d_animation", "clay", "comic", "cyberpunk") Do not include style parameter unless needed',
|
| 46 |
-
examples=['anime'],
|
| 47 |
-
)
|
| 48 |
-
template_id: Optional[int] = Field(
|
| 49 |
-
None,
|
| 50 |
-
description='Template ID (template_id must be activated before use)',
|
| 51 |
-
examples=[302325299692608],
|
| 52 |
-
)
|
| 53 |
-
water_mark: Optional[bool] = Field(
|
| 54 |
-
False,
|
| 55 |
-
description='Watermark (true: add watermark, false: no watermark)',
|
| 56 |
-
examples=[False],
|
| 57 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/__init__.py
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
comfy_api_nodes/apis/bfl_api.py
DELETED
|
@@ -1,174 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from enum import Enum
|
| 4 |
-
from typing import Any, Dict, Optional
|
| 5 |
-
|
| 6 |
-
from pydantic import BaseModel, Field, confloat, conint
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
class BFLOutputFormat(str, Enum):
|
| 10 |
-
png = 'png'
|
| 11 |
-
jpeg = 'jpeg'
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class BFLFluxExpandImageRequest(BaseModel):
|
| 15 |
-
prompt: str = Field(..., description='The description of the changes you want to make. This text guides the expansion process, allowing you to specify features, styles, or modifications for the expanded areas.')
|
| 16 |
-
prompt_upsampling: Optional[bool] = Field(
|
| 17 |
-
None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
|
| 18 |
-
)
|
| 19 |
-
seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
|
| 20 |
-
top: conint(ge=0, le=2048) = Field(..., description='Number of pixels to expand at the top of the image')
|
| 21 |
-
bottom: conint(ge=0, le=2048) = Field(..., description='Number of pixels to expand at the bottom of the image')
|
| 22 |
-
left: conint(ge=0, le=2048) = Field(..., description='Number of pixels to expand at the left side of the image')
|
| 23 |
-
right: conint(ge=0, le=2048) = Field(..., description='Number of pixels to expand at the right side of the image')
|
| 24 |
-
steps: conint(ge=15, le=50) = Field(..., description='Number of steps for the image generation process')
|
| 25 |
-
guidance: confloat(ge=1.5, le=100) = Field(..., description='Guidance strength for the image generation process')
|
| 26 |
-
safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
|
| 27 |
-
6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
|
| 28 |
-
)
|
| 29 |
-
output_format: Optional[BFLOutputFormat] = Field(
|
| 30 |
-
BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
|
| 31 |
-
)
|
| 32 |
-
image: str = Field(None, description='A Base64-encoded string representing the image you wish to expand')
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
class BFLFluxFillImageRequest(BaseModel):
|
| 36 |
-
prompt: str = Field(..., description='The description of the changes you want to make. This text guides the expansion process, allowing you to specify features, styles, or modifications for the expanded areas.')
|
| 37 |
-
prompt_upsampling: Optional[bool] = Field(
|
| 38 |
-
None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
|
| 39 |
-
)
|
| 40 |
-
seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
|
| 41 |
-
steps: conint(ge=15, le=50) = Field(..., description='Number of steps for the image generation process')
|
| 42 |
-
guidance: confloat(ge=1.5, le=100) = Field(..., description='Guidance strength for the image generation process')
|
| 43 |
-
safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
|
| 44 |
-
6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
|
| 45 |
-
)
|
| 46 |
-
output_format: Optional[BFLOutputFormat] = Field(
|
| 47 |
-
BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
|
| 48 |
-
)
|
| 49 |
-
image: str = Field(None, description='A Base64-encoded string representing the image you wish to modify. Can contain alpha mask if desired.')
|
| 50 |
-
mask: str = Field(None, description='A Base64-encoded string representing the mask of the areas you with to modify.')
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
class BFLFluxCannyImageRequest(BaseModel):
|
| 54 |
-
prompt: str = Field(..., description='Text prompt for image generation')
|
| 55 |
-
prompt_upsampling: Optional[bool] = Field(
|
| 56 |
-
None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
|
| 57 |
-
)
|
| 58 |
-
canny_low_threshold: Optional[int] = Field(None, description='Low threshold for Canny edge detection')
|
| 59 |
-
canny_high_threshold: Optional[int] = Field(None, description='High threshold for Canny edge detection')
|
| 60 |
-
seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
|
| 61 |
-
steps: conint(ge=15, le=50) = Field(..., description='Number of steps for the image generation process')
|
| 62 |
-
guidance: confloat(ge=1, le=100) = Field(..., description='Guidance strength for the image generation process')
|
| 63 |
-
safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
|
| 64 |
-
6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
|
| 65 |
-
)
|
| 66 |
-
output_format: Optional[BFLOutputFormat] = Field(
|
| 67 |
-
BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
|
| 68 |
-
)
|
| 69 |
-
control_image: Optional[str] = Field(None, description='Base64 encoded image to use as control input if no preprocessed image is provided')
|
| 70 |
-
preprocessed_image: Optional[str] = Field(None, description='Optional pre-processed image that will bypass the control preprocessing step')
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
class BFLFluxDepthImageRequest(BaseModel):
|
| 74 |
-
prompt: str = Field(..., description='Text prompt for image generation')
|
| 75 |
-
prompt_upsampling: Optional[bool] = Field(
|
| 76 |
-
None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
|
| 77 |
-
)
|
| 78 |
-
seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
|
| 79 |
-
steps: conint(ge=15, le=50) = Field(..., description='Number of steps for the image generation process')
|
| 80 |
-
guidance: confloat(ge=1, le=100) = Field(..., description='Guidance strength for the image generation process')
|
| 81 |
-
safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
|
| 82 |
-
6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
|
| 83 |
-
)
|
| 84 |
-
output_format: Optional[BFLOutputFormat] = Field(
|
| 85 |
-
BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
|
| 86 |
-
)
|
| 87 |
-
control_image: Optional[str] = Field(None, description='Base64 encoded image to use as control input if no preprocessed image is provided')
|
| 88 |
-
preprocessed_image: Optional[str] = Field(None, description='Optional pre-processed image that will bypass the control preprocessing step')
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
class BFLFluxProGenerateRequest(BaseModel):
|
| 92 |
-
prompt: str = Field(..., description='The text prompt for image generation.')
|
| 93 |
-
prompt_upsampling: Optional[bool] = Field(
|
| 94 |
-
None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
|
| 95 |
-
)
|
| 96 |
-
seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
|
| 97 |
-
width: conint(ge=256, le=1440) = Field(1024, description='Width of the generated image in pixels. Must be a multiple of 32.')
|
| 98 |
-
height: conint(ge=256, le=1440) = Field(768, description='Height of the generated image in pixels. Must be a multiple of 32.')
|
| 99 |
-
safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
|
| 100 |
-
6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
|
| 101 |
-
)
|
| 102 |
-
output_format: Optional[BFLOutputFormat] = Field(
|
| 103 |
-
BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
|
| 104 |
-
)
|
| 105 |
-
image_prompt: Optional[str] = Field(None, description='Optional image to remix in base64 format')
|
| 106 |
-
# image_prompt_strength: Optional[confloat(ge=0.0, le=1.0)] = Field(
|
| 107 |
-
# None, description='Blend between the prompt and the image prompt.'
|
| 108 |
-
# )
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
class BFLFluxKontextProGenerateRequest(BaseModel):
|
| 112 |
-
prompt: str = Field(..., description='The text prompt for what you wannt to edit.')
|
| 113 |
-
input_image: Optional[str] = Field(None, description='Image to edit in base64 format')
|
| 114 |
-
seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
|
| 115 |
-
guidance: confloat(ge=0.1, le=99.0) = Field(..., description='Guidance strength for the image generation process')
|
| 116 |
-
steps: conint(ge=1, le=150) = Field(..., description='Number of steps for the image generation process')
|
| 117 |
-
safety_tolerance: Optional[conint(ge=0, le=2)] = Field(
|
| 118 |
-
2, description='Tolerance level for input and output moderation. Between 0 and 2, 0 being most strict, 6 being least strict. Defaults to 2.'
|
| 119 |
-
)
|
| 120 |
-
output_format: Optional[BFLOutputFormat] = Field(
|
| 121 |
-
BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
|
| 122 |
-
)
|
| 123 |
-
aspect_ratio: Optional[str] = Field(None, description='Aspect ratio of the image between 21:9 and 9:21.')
|
| 124 |
-
prompt_upsampling: Optional[bool] = Field(
|
| 125 |
-
None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
class BFLFluxProUltraGenerateRequest(BaseModel):
|
| 130 |
-
prompt: str = Field(..., description='The text prompt for image generation.')
|
| 131 |
-
prompt_upsampling: Optional[bool] = Field(
|
| 132 |
-
None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
|
| 133 |
-
)
|
| 134 |
-
seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
|
| 135 |
-
aspect_ratio: Optional[str] = Field(None, description='Aspect ratio of the image between 21:9 and 9:21.')
|
| 136 |
-
safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
|
| 137 |
-
6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
|
| 138 |
-
)
|
| 139 |
-
output_format: Optional[BFLOutputFormat] = Field(
|
| 140 |
-
BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
|
| 141 |
-
)
|
| 142 |
-
raw: Optional[bool] = Field(None, description='Generate less processed, more natural-looking images.')
|
| 143 |
-
image_prompt: Optional[str] = Field(None, description='Optional image to remix in base64 format')
|
| 144 |
-
image_prompt_strength: Optional[confloat(ge=0.0, le=1.0)] = Field(
|
| 145 |
-
None, description='Blend between the prompt and the image prompt.'
|
| 146 |
-
)
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
class BFLFluxProGenerateResponse(BaseModel):
|
| 150 |
-
id: str = Field(..., description='The unique identifier for the generation task.')
|
| 151 |
-
polling_url: str = Field(..., description='URL to poll for the generation result.')
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
class BFLStatus(str, Enum):
|
| 155 |
-
task_not_found = "Task not found"
|
| 156 |
-
pending = "Pending"
|
| 157 |
-
request_moderated = "Request Moderated"
|
| 158 |
-
content_moderated = "Content Moderated"
|
| 159 |
-
ready = "Ready"
|
| 160 |
-
error = "Error"
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
class BFLFluxProStatusResponse(BaseModel):
|
| 164 |
-
id: str = Field(..., description="The unique identifier for the generation task.")
|
| 165 |
-
status: BFLStatus = Field(..., description="The status of the task.")
|
| 166 |
-
result: Optional[Dict[str, Any]] = Field(
|
| 167 |
-
None, description="The result of the task (null if not completed)."
|
| 168 |
-
)
|
| 169 |
-
progress: confloat(ge=0.0, le=1.0) = Field(
|
| 170 |
-
..., description="The progress of the task (0.0 to 1.0)."
|
| 171 |
-
)
|
| 172 |
-
details: Optional[Dict[str, Any]] = Field(
|
| 173 |
-
None, description="Additional details about the task (null if not available)."
|
| 174 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/client.py
DELETED
|
@@ -1,1126 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
API Client Framework for api.comfy.org.
|
| 3 |
-
|
| 4 |
-
This module provides a flexible framework for making API requests from ComfyUI nodes.
|
| 5 |
-
It supports both synchronous and asynchronous API operations with proper type validation.
|
| 6 |
-
|
| 7 |
-
Key Components:
|
| 8 |
-
--------------
|
| 9 |
-
1. ApiClient - Handles HTTP requests with authentication and error handling
|
| 10 |
-
2. ApiEndpoint - Defines a single HTTP endpoint with its request/response models
|
| 11 |
-
3. ApiOperation - Executes a single synchronous API operation
|
| 12 |
-
|
| 13 |
-
Usage Examples:
|
| 14 |
-
--------------
|
| 15 |
-
|
| 16 |
-
# Example 1: Synchronous API Operation
|
| 17 |
-
# ------------------------------------
|
| 18 |
-
# For a simple API call that returns the result immediately:
|
| 19 |
-
|
| 20 |
-
# 1. Create the API client
|
| 21 |
-
api_client = ApiClient(
|
| 22 |
-
base_url="https://api.example.com",
|
| 23 |
-
auth_token="your_auth_token_here",
|
| 24 |
-
comfy_api_key="your_comfy_api_key_here",
|
| 25 |
-
timeout=30.0,
|
| 26 |
-
verify_ssl=True
|
| 27 |
-
)
|
| 28 |
-
|
| 29 |
-
# 2. Define the endpoint
|
| 30 |
-
user_info_endpoint = ApiEndpoint(
|
| 31 |
-
path="/v1/users/me",
|
| 32 |
-
method=HttpMethod.GET,
|
| 33 |
-
request_model=EmptyRequest, # No request body needed
|
| 34 |
-
response_model=UserProfile, # Pydantic model for the response
|
| 35 |
-
query_params=None
|
| 36 |
-
)
|
| 37 |
-
|
| 38 |
-
# 3. Create the request object
|
| 39 |
-
request = EmptyRequest()
|
| 40 |
-
|
| 41 |
-
# 4. Create and execute the operation
|
| 42 |
-
operation = ApiOperation(
|
| 43 |
-
endpoint=user_info_endpoint,
|
| 44 |
-
request=request
|
| 45 |
-
)
|
| 46 |
-
user_profile = operation.execute(client=api_client) # Returns immediately with the result
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
# Example 2: Asynchronous API Operation with Polling
|
| 50 |
-
# -------------------------------------------------
|
| 51 |
-
# For an API that starts a task and requires polling for completion:
|
| 52 |
-
|
| 53 |
-
# 1. Define the endpoints (initial request and polling)
|
| 54 |
-
generate_image_endpoint = ApiEndpoint(
|
| 55 |
-
path="/v1/images/generate",
|
| 56 |
-
method=HttpMethod.POST,
|
| 57 |
-
request_model=ImageGenerationRequest,
|
| 58 |
-
response_model=TaskCreatedResponse,
|
| 59 |
-
query_params=None
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
check_task_endpoint = ApiEndpoint(
|
| 63 |
-
path="/v1/tasks/{task_id}",
|
| 64 |
-
method=HttpMethod.GET,
|
| 65 |
-
request_model=EmptyRequest,
|
| 66 |
-
response_model=ImageGenerationResult,
|
| 67 |
-
query_params=None
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
-
# 2. Create the request object
|
| 71 |
-
request = ImageGenerationRequest(
|
| 72 |
-
prompt="a beautiful sunset over mountains",
|
| 73 |
-
width=1024,
|
| 74 |
-
height=1024,
|
| 75 |
-
num_images=1
|
| 76 |
-
)
|
| 77 |
-
|
| 78 |
-
# 3. Create and execute the polling operation
|
| 79 |
-
operation = PollingOperation(
|
| 80 |
-
initial_endpoint=generate_image_endpoint,
|
| 81 |
-
initial_request=request,
|
| 82 |
-
poll_endpoint=check_task_endpoint,
|
| 83 |
-
task_id_field="task_id",
|
| 84 |
-
status_field="status",
|
| 85 |
-
completed_statuses=["completed"],
|
| 86 |
-
failed_statuses=["failed", "error"]
|
| 87 |
-
)
|
| 88 |
-
|
| 89 |
-
# This will make the initial request and then poll until completion
|
| 90 |
-
result = operation.execute(client=api_client) # Returns the final ImageGenerationResult when done
|
| 91 |
-
"""
|
| 92 |
-
|
| 93 |
-
from __future__ import annotations
|
| 94 |
-
import logging
|
| 95 |
-
import time
|
| 96 |
-
import io
|
| 97 |
-
import socket
|
| 98 |
-
from typing import Dict, Type, Optional, Any, TypeVar, Generic, Callable, Tuple
|
| 99 |
-
from enum import Enum
|
| 100 |
-
import json
|
| 101 |
-
import requests
|
| 102 |
-
from urllib.parse import urljoin, urlparse
|
| 103 |
-
from pydantic import BaseModel, Field
|
| 104 |
-
import uuid # For generating unique operation IDs
|
| 105 |
-
|
| 106 |
-
from server import PromptServer
|
| 107 |
-
from comfy.cli_args import args
|
| 108 |
-
from comfy import utils
|
| 109 |
-
from . import request_logger
|
| 110 |
-
|
| 111 |
-
T = TypeVar("T", bound=BaseModel)
|
| 112 |
-
R = TypeVar("R", bound=BaseModel)
|
| 113 |
-
P = TypeVar("P", bound=BaseModel) # For poll response
|
| 114 |
-
|
| 115 |
-
PROGRESS_BAR_MAX = 100
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
class NetworkError(Exception):
|
| 119 |
-
"""Base exception for network-related errors with diagnostic information."""
|
| 120 |
-
pass
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
class LocalNetworkError(NetworkError):
|
| 124 |
-
"""Exception raised when local network connectivity issues are detected."""
|
| 125 |
-
pass
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
class ApiServerError(NetworkError):
|
| 129 |
-
"""Exception raised when the API server is unreachable but internet is working."""
|
| 130 |
-
pass
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
class EmptyRequest(BaseModel):
|
| 134 |
-
"""Base class for empty request bodies.
|
| 135 |
-
For GET requests, fields will be sent as query parameters."""
|
| 136 |
-
|
| 137 |
-
pass
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
class UploadRequest(BaseModel):
|
| 141 |
-
file_name: str = Field(..., description="Filename to upload")
|
| 142 |
-
content_type: Optional[str] = Field(
|
| 143 |
-
None,
|
| 144 |
-
description="Mime type of the file. For example: image/png, image/jpeg, video/mp4, etc.",
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
class UploadResponse(BaseModel):
|
| 149 |
-
download_url: str = Field(..., description="URL to GET uploaded file")
|
| 150 |
-
upload_url: str = Field(..., description="URL to PUT file to upload")
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
class HttpMethod(str, Enum):
|
| 154 |
-
GET = "GET"
|
| 155 |
-
POST = "POST"
|
| 156 |
-
PUT = "PUT"
|
| 157 |
-
DELETE = "DELETE"
|
| 158 |
-
PATCH = "PATCH"
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
class ApiClient:
|
| 162 |
-
"""
|
| 163 |
-
Client for making HTTP requests to an API with authentication, error handling, and retry logic.
|
| 164 |
-
"""
|
| 165 |
-
|
| 166 |
-
def __init__(
|
| 167 |
-
self,
|
| 168 |
-
base_url: str,
|
| 169 |
-
auth_token: Optional[str] = None,
|
| 170 |
-
comfy_api_key: Optional[str] = None,
|
| 171 |
-
timeout: float = 3600.0,
|
| 172 |
-
verify_ssl: bool = True,
|
| 173 |
-
max_retries: int = 3,
|
| 174 |
-
retry_delay: float = 1.0,
|
| 175 |
-
retry_backoff_factor: float = 2.0,
|
| 176 |
-
retry_status_codes: Optional[Tuple[int, ...]] = None,
|
| 177 |
-
):
|
| 178 |
-
self.base_url = base_url
|
| 179 |
-
self.auth_token = auth_token
|
| 180 |
-
self.comfy_api_key = comfy_api_key
|
| 181 |
-
self.timeout = timeout
|
| 182 |
-
self.verify_ssl = verify_ssl
|
| 183 |
-
self.max_retries = max_retries
|
| 184 |
-
self.retry_delay = retry_delay
|
| 185 |
-
self.retry_backoff_factor = retry_backoff_factor
|
| 186 |
-
# Default retry status codes: 408 (Request Timeout), 429 (Too Many Requests),
|
| 187 |
-
# 500, 502, 503, 504 (Server Errors)
|
| 188 |
-
self.retry_status_codes = retry_status_codes or (408, 429, 500, 502, 503, 504)
|
| 189 |
-
|
| 190 |
-
def _generate_operation_id(self, path: str) -> str:
|
| 191 |
-
"""Generates a unique operation ID for logging."""
|
| 192 |
-
return f"{path.strip('/').replace('/', '_')}_{uuid.uuid4().hex[:8]}"
|
| 193 |
-
|
| 194 |
-
def _create_json_payload_args(
|
| 195 |
-
self,
|
| 196 |
-
data: Optional[Dict[str, Any]] = None,
|
| 197 |
-
headers: Optional[Dict[str, str]] = None,
|
| 198 |
-
) -> Dict[str, Any]:
|
| 199 |
-
return {
|
| 200 |
-
"json": data,
|
| 201 |
-
"headers": headers,
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
def _create_form_data_args(
|
| 205 |
-
self,
|
| 206 |
-
data: Dict[str, Any],
|
| 207 |
-
files: Dict[str, Any],
|
| 208 |
-
headers: Optional[Dict[str, str]] = None,
|
| 209 |
-
multipart_parser = None,
|
| 210 |
-
) -> Dict[str, Any]:
|
| 211 |
-
if headers and "Content-Type" in headers:
|
| 212 |
-
del headers["Content-Type"]
|
| 213 |
-
|
| 214 |
-
if multipart_parser:
|
| 215 |
-
data = multipart_parser(data)
|
| 216 |
-
|
| 217 |
-
return {
|
| 218 |
-
"data": data,
|
| 219 |
-
"files": files,
|
| 220 |
-
"headers": headers,
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
def _create_urlencoded_form_data_args(
|
| 224 |
-
self,
|
| 225 |
-
data: Dict[str, Any],
|
| 226 |
-
headers: Optional[Dict[str, str]] = None,
|
| 227 |
-
) -> Dict[str, Any]:
|
| 228 |
-
headers = headers or {}
|
| 229 |
-
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
| 230 |
-
|
| 231 |
-
return {
|
| 232 |
-
"data": data,
|
| 233 |
-
"headers": headers,
|
| 234 |
-
}
|
| 235 |
-
|
| 236 |
-
def get_headers(self) -> Dict[str, str]:
|
| 237 |
-
"""Get headers for API requests, including authentication if available"""
|
| 238 |
-
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
| 239 |
-
|
| 240 |
-
if self.auth_token:
|
| 241 |
-
headers["Authorization"] = f"Bearer {self.auth_token}"
|
| 242 |
-
elif self.comfy_api_key:
|
| 243 |
-
headers["X-API-KEY"] = self.comfy_api_key
|
| 244 |
-
|
| 245 |
-
return headers
|
| 246 |
-
|
| 247 |
-
def _check_connectivity(self, target_url: str) -> Dict[str, bool]:
|
| 248 |
-
"""
|
| 249 |
-
Check connectivity to determine if network issues are local or server-related.
|
| 250 |
-
|
| 251 |
-
Args:
|
| 252 |
-
target_url: URL to check connectivity to
|
| 253 |
-
|
| 254 |
-
Returns:
|
| 255 |
-
Dictionary with connectivity status details
|
| 256 |
-
"""
|
| 257 |
-
results = {
|
| 258 |
-
"internet_accessible": False,
|
| 259 |
-
"api_accessible": False,
|
| 260 |
-
"is_local_issue": False,
|
| 261 |
-
"is_api_issue": False
|
| 262 |
-
}
|
| 263 |
-
|
| 264 |
-
# First check basic internet connectivity using a reliable external site
|
| 265 |
-
try:
|
| 266 |
-
# Use a reliable external domain for checking basic connectivity
|
| 267 |
-
check_response = requests.get("https://www.google.com",
|
| 268 |
-
timeout=5.0,
|
| 269 |
-
verify=self.verify_ssl)
|
| 270 |
-
if check_response.status_code < 500:
|
| 271 |
-
results["internet_accessible"] = True
|
| 272 |
-
except (requests.RequestException, socket.error):
|
| 273 |
-
results["internet_accessible"] = False
|
| 274 |
-
results["is_local_issue"] = True
|
| 275 |
-
return results
|
| 276 |
-
|
| 277 |
-
# Now check API server connectivity
|
| 278 |
-
try:
|
| 279 |
-
# Extract domain from the target URL to do a simpler health check
|
| 280 |
-
parsed_url = urlparse(target_url)
|
| 281 |
-
api_base = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
| 282 |
-
|
| 283 |
-
# Try to reach the API domain
|
| 284 |
-
api_response = requests.get(f"{api_base}/health", timeout=5.0, verify=self.verify_ssl)
|
| 285 |
-
if api_response.status_code < 500:
|
| 286 |
-
results["api_accessible"] = True
|
| 287 |
-
else:
|
| 288 |
-
results["api_accessible"] = False
|
| 289 |
-
results["is_api_issue"] = True
|
| 290 |
-
except requests.RequestException:
|
| 291 |
-
results["api_accessible"] = False
|
| 292 |
-
# If we can reach the internet but not the API, it's an API issue
|
| 293 |
-
results["is_api_issue"] = True
|
| 294 |
-
|
| 295 |
-
return results
|
| 296 |
-
|
| 297 |
-
def request(
|
| 298 |
-
self,
|
| 299 |
-
method: str,
|
| 300 |
-
path: str,
|
| 301 |
-
params: Optional[Dict[str, Any]] = None,
|
| 302 |
-
data: Optional[Dict[str, Any]] = None,
|
| 303 |
-
files: Optional[Dict[str, Any]] = None,
|
| 304 |
-
headers: Optional[Dict[str, str]] = None,
|
| 305 |
-
content_type: str = "application/json",
|
| 306 |
-
multipart_parser: Callable = None,
|
| 307 |
-
retry_count: int = 0, # Used internally for tracking retries
|
| 308 |
-
) -> Dict[str, Any]:
|
| 309 |
-
"""
|
| 310 |
-
Make an HTTP request to the API with automatic retries for transient errors.
|
| 311 |
-
|
| 312 |
-
Args:
|
| 313 |
-
method: HTTP method (GET, POST, etc.)
|
| 314 |
-
path: API endpoint path (will be joined with base_url)
|
| 315 |
-
params: Query parameters
|
| 316 |
-
data: body data
|
| 317 |
-
files: Files to upload
|
| 318 |
-
headers: Additional headers
|
| 319 |
-
content_type: Content type of the request. Defaults to application/json.
|
| 320 |
-
retry_count: Internal parameter for tracking retries, do not set manually
|
| 321 |
-
|
| 322 |
-
Returns:
|
| 323 |
-
Parsed JSON response
|
| 324 |
-
|
| 325 |
-
Raises:
|
| 326 |
-
LocalNetworkError: If local network connectivity issues are detected
|
| 327 |
-
ApiServerError: If the API server is unreachable but internet is working
|
| 328 |
-
Exception: For other request failures
|
| 329 |
-
"""
|
| 330 |
-
# Use urljoin but ensure path is relative to avoid absolute path behavior
|
| 331 |
-
relative_path = path.lstrip('/')
|
| 332 |
-
url = urljoin(self.base_url, relative_path)
|
| 333 |
-
self.check_auth(self.auth_token, self.comfy_api_key)
|
| 334 |
-
# Combine default headers with any provided headers
|
| 335 |
-
request_headers = self.get_headers()
|
| 336 |
-
if headers:
|
| 337 |
-
request_headers.update(headers)
|
| 338 |
-
|
| 339 |
-
# Let requests handle the content type when files are present.
|
| 340 |
-
if files:
|
| 341 |
-
del request_headers["Content-Type"]
|
| 342 |
-
|
| 343 |
-
logging.debug(f"[DEBUG] Request Headers: {request_headers}")
|
| 344 |
-
logging.debug(f"[DEBUG] Files: {files}")
|
| 345 |
-
logging.debug(f"[DEBUG] Params: {params}")
|
| 346 |
-
logging.debug(f"[DEBUG] Data: {data}")
|
| 347 |
-
|
| 348 |
-
if content_type == "application/x-www-form-urlencoded":
|
| 349 |
-
payload_args = self._create_urlencoded_form_data_args(data, request_headers)
|
| 350 |
-
elif content_type == "multipart/form-data":
|
| 351 |
-
payload_args = self._create_form_data_args(
|
| 352 |
-
data, files, request_headers, multipart_parser
|
| 353 |
-
)
|
| 354 |
-
else:
|
| 355 |
-
payload_args = self._create_json_payload_args(data, request_headers)
|
| 356 |
-
|
| 357 |
-
operation_id = self._generate_operation_id(path)
|
| 358 |
-
request_logger.log_request_response(
|
| 359 |
-
operation_id=operation_id,
|
| 360 |
-
request_method=method,
|
| 361 |
-
request_url=url,
|
| 362 |
-
request_headers=request_headers,
|
| 363 |
-
request_params=params,
|
| 364 |
-
request_data=data if content_type == "application/json" else "[form-data or other]"
|
| 365 |
-
)
|
| 366 |
-
|
| 367 |
-
try:
|
| 368 |
-
response = requests.request(
|
| 369 |
-
method=method,
|
| 370 |
-
url=url,
|
| 371 |
-
params=params,
|
| 372 |
-
timeout=self.timeout,
|
| 373 |
-
verify=self.verify_ssl,
|
| 374 |
-
**payload_args,
|
| 375 |
-
)
|
| 376 |
-
|
| 377 |
-
# Check if we should retry based on status code
|
| 378 |
-
if (response.status_code in self.retry_status_codes and
|
| 379 |
-
retry_count < self.max_retries):
|
| 380 |
-
|
| 381 |
-
# Calculate delay with exponential backoff
|
| 382 |
-
delay = self.retry_delay * (self.retry_backoff_factor ** retry_count)
|
| 383 |
-
|
| 384 |
-
logging.warning(
|
| 385 |
-
f"Request failed with status {response.status_code}. "
|
| 386 |
-
f"Retrying in {delay:.2f}s ({retry_count + 1}/{self.max_retries})"
|
| 387 |
-
)
|
| 388 |
-
|
| 389 |
-
time.sleep(delay)
|
| 390 |
-
return self.request(
|
| 391 |
-
method=method,
|
| 392 |
-
path=path,
|
| 393 |
-
params=params,
|
| 394 |
-
data=data,
|
| 395 |
-
files=files,
|
| 396 |
-
headers=headers,
|
| 397 |
-
content_type=content_type,
|
| 398 |
-
multipart_parser=multipart_parser,
|
| 399 |
-
retry_count=retry_count + 1,
|
| 400 |
-
)
|
| 401 |
-
|
| 402 |
-
# Raise exception for error status codes
|
| 403 |
-
response.raise_for_status()
|
| 404 |
-
|
| 405 |
-
# Log successful response
|
| 406 |
-
response_content_to_log = response.content
|
| 407 |
-
try:
|
| 408 |
-
# Attempt to parse JSON for prettier logging, fallback to raw content
|
| 409 |
-
response_content_to_log = response.json()
|
| 410 |
-
except json.JSONDecodeError:
|
| 411 |
-
pass # Keep as bytes/str if not JSON
|
| 412 |
-
|
| 413 |
-
request_logger.log_request_response(
|
| 414 |
-
operation_id=operation_id,
|
| 415 |
-
request_method=method, # Pass request details again for context in log
|
| 416 |
-
request_url=url,
|
| 417 |
-
response_status_code=response.status_code,
|
| 418 |
-
response_headers=dict(response.headers),
|
| 419 |
-
response_content=response_content_to_log
|
| 420 |
-
)
|
| 421 |
-
|
| 422 |
-
except requests.ConnectionError as e:
|
| 423 |
-
error_message = f"ConnectionError: {str(e)}"
|
| 424 |
-
request_logger.log_request_response(
|
| 425 |
-
operation_id=operation_id,
|
| 426 |
-
request_method=method,
|
| 427 |
-
request_url=url,
|
| 428 |
-
error_message=error_message
|
| 429 |
-
)
|
| 430 |
-
# Only perform connectivity check if we've exhausted all retries
|
| 431 |
-
if retry_count >= self.max_retries:
|
| 432 |
-
# Check connectivity to determine if it's a local or API issue
|
| 433 |
-
connectivity = self._check_connectivity(self.base_url)
|
| 434 |
-
|
| 435 |
-
if connectivity["is_local_issue"]:
|
| 436 |
-
raise LocalNetworkError(
|
| 437 |
-
"Unable to connect to the API server due to local network issues. "
|
| 438 |
-
"Please check your internet connection and try again."
|
| 439 |
-
) from e
|
| 440 |
-
elif connectivity["is_api_issue"]:
|
| 441 |
-
raise ApiServerError(
|
| 442 |
-
f"The API server at {self.base_url} is currently unreachable. "
|
| 443 |
-
f"The service may be experiencing issues. Please try again later."
|
| 444 |
-
) from e
|
| 445 |
-
|
| 446 |
-
# If we haven't exhausted retries yet, retry the request
|
| 447 |
-
if retry_count < self.max_retries:
|
| 448 |
-
delay = self.retry_delay * (self.retry_backoff_factor ** retry_count)
|
| 449 |
-
logging.warning(
|
| 450 |
-
f"Connection error: {str(e)}. "
|
| 451 |
-
f"Retrying in {delay:.2f}s ({retry_count + 1}/{self.max_retries})"
|
| 452 |
-
)
|
| 453 |
-
time.sleep(delay)
|
| 454 |
-
return self.request(
|
| 455 |
-
method=method,
|
| 456 |
-
path=path,
|
| 457 |
-
params=params,
|
| 458 |
-
data=data,
|
| 459 |
-
files=files,
|
| 460 |
-
headers=headers,
|
| 461 |
-
content_type=content_type,
|
| 462 |
-
multipart_parser=multipart_parser,
|
| 463 |
-
retry_count=retry_count + 1,
|
| 464 |
-
)
|
| 465 |
-
|
| 466 |
-
# If we've exhausted retries and didn't identify the specific issue,
|
| 467 |
-
# raise a generic exception
|
| 468 |
-
final_error_message = (
|
| 469 |
-
f"Unable to connect to the API server after {self.max_retries} attempts. "
|
| 470 |
-
f"Please check your internet connection or try again later."
|
| 471 |
-
)
|
| 472 |
-
request_logger.log_request_response( # Log final failure
|
| 473 |
-
operation_id=operation_id,
|
| 474 |
-
request_method=method, request_url=url,
|
| 475 |
-
error_message=final_error_message
|
| 476 |
-
)
|
| 477 |
-
raise Exception(final_error_message) from e
|
| 478 |
-
|
| 479 |
-
except requests.Timeout as e:
|
| 480 |
-
error_message = f"Timeout: {str(e)}"
|
| 481 |
-
request_logger.log_request_response(
|
| 482 |
-
operation_id=operation_id,
|
| 483 |
-
request_method=method, request_url=url,
|
| 484 |
-
error_message=error_message
|
| 485 |
-
)
|
| 486 |
-
# Retry timeouts if we haven't exhausted retries
|
| 487 |
-
if retry_count < self.max_retries:
|
| 488 |
-
delay = self.retry_delay * (self.retry_backoff_factor ** retry_count)
|
| 489 |
-
logging.warning(
|
| 490 |
-
f"Request timed out. "
|
| 491 |
-
f"Retrying in {delay:.2f}s ({retry_count + 1}/{self.max_retries})"
|
| 492 |
-
)
|
| 493 |
-
time.sleep(delay)
|
| 494 |
-
return self.request(
|
| 495 |
-
method=method,
|
| 496 |
-
path=path,
|
| 497 |
-
params=params,
|
| 498 |
-
data=data,
|
| 499 |
-
files=files,
|
| 500 |
-
headers=headers,
|
| 501 |
-
content_type=content_type,
|
| 502 |
-
multipart_parser=multipart_parser,
|
| 503 |
-
retry_count=retry_count + 1,
|
| 504 |
-
)
|
| 505 |
-
final_error_message = (
|
| 506 |
-
f"Request timed out after {self.timeout} seconds and {self.max_retries} retry attempts. "
|
| 507 |
-
f"The server might be experiencing high load or the operation is taking longer than expected."
|
| 508 |
-
)
|
| 509 |
-
request_logger.log_request_response( # Log final failure
|
| 510 |
-
operation_id=operation_id,
|
| 511 |
-
request_method=method, request_url=url,
|
| 512 |
-
error_message=final_error_message
|
| 513 |
-
)
|
| 514 |
-
raise Exception(final_error_message) from e
|
| 515 |
-
|
| 516 |
-
except requests.HTTPError as e:
|
| 517 |
-
status_code = e.response.status_code if hasattr(e, "response") else None
|
| 518 |
-
original_error_message = f"HTTP Error: {str(e)}"
|
| 519 |
-
error_content_for_log = None
|
| 520 |
-
if hasattr(e, "response") and e.response is not None:
|
| 521 |
-
error_content_for_log = e.response.content
|
| 522 |
-
try:
|
| 523 |
-
error_content_for_log = e.response.json()
|
| 524 |
-
except json.JSONDecodeError:
|
| 525 |
-
pass
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
# Try to extract detailed error message from JSON response for user display
|
| 529 |
-
# but log the full error content.
|
| 530 |
-
user_display_error_message = original_error_message
|
| 531 |
-
|
| 532 |
-
try:
|
| 533 |
-
if hasattr(e, "response") and e.response is not None and e.response.content:
|
| 534 |
-
error_json = e.response.json()
|
| 535 |
-
if "error" in error_json and "message" in error_json["error"]:
|
| 536 |
-
user_display_error_message = f"API Error: {error_json['error']['message']}"
|
| 537 |
-
if "type" in error_json["error"]:
|
| 538 |
-
user_display_error_message += f" (Type: {error_json['error']['type']})"
|
| 539 |
-
elif isinstance(error_json, dict): # Handle cases where error is just a JSON dict
|
| 540 |
-
user_display_error_message = f"API Error: {json.dumps(error_json)}"
|
| 541 |
-
else: # Non-dict JSON error
|
| 542 |
-
user_display_error_message = f"API Error: {str(error_json)}"
|
| 543 |
-
except json.JSONDecodeError:
|
| 544 |
-
# If not JSON, use the raw content if it's not too long, or a summary
|
| 545 |
-
if hasattr(e, "response") and e.response is not None and e.response.content:
|
| 546 |
-
raw_content = e.response.content.decode(errors='ignore')
|
| 547 |
-
if len(raw_content) < 200: # Arbitrary limit for display
|
| 548 |
-
user_display_error_message = f"API Error (raw): {raw_content}"
|
| 549 |
-
else:
|
| 550 |
-
user_display_error_message = f"API Error (raw, status {status_code})"
|
| 551 |
-
|
| 552 |
-
request_logger.log_request_response(
|
| 553 |
-
operation_id=operation_id,
|
| 554 |
-
request_method=method, request_url=url,
|
| 555 |
-
response_status_code=status_code,
|
| 556 |
-
response_headers=dict(e.response.headers) if hasattr(e, "response") and e.response is not None else None,
|
| 557 |
-
response_content=error_content_for_log,
|
| 558 |
-
error_message=original_error_message # Log the original exception string as error
|
| 559 |
-
)
|
| 560 |
-
|
| 561 |
-
logging.debug(f"[DEBUG] API Error: {user_display_error_message} (Status: {status_code})")
|
| 562 |
-
if hasattr(e, "response") and e.response is not None and e.response.content:
|
| 563 |
-
logging.debug(f"[DEBUG] Response content: {e.response.content}")
|
| 564 |
-
|
| 565 |
-
# Retry if the status code is in our retry list and we haven't exhausted retries
|
| 566 |
-
if (status_code in self.retry_status_codes and
|
| 567 |
-
retry_count < self.max_retries):
|
| 568 |
-
|
| 569 |
-
delay = self.retry_delay * (self.retry_backoff_factor ** retry_count)
|
| 570 |
-
logging.warning(
|
| 571 |
-
f"HTTP error {status_code}. "
|
| 572 |
-
f"Retrying in {delay:.2f}s ({retry_count + 1}/{self.max_retries})"
|
| 573 |
-
)
|
| 574 |
-
time.sleep(delay)
|
| 575 |
-
return self.request(
|
| 576 |
-
method=method,
|
| 577 |
-
path=path,
|
| 578 |
-
params=params,
|
| 579 |
-
data=data,
|
| 580 |
-
files=files,
|
| 581 |
-
headers=headers,
|
| 582 |
-
content_type=content_type,
|
| 583 |
-
multipart_parser=multipart_parser,
|
| 584 |
-
retry_count=retry_count + 1,
|
| 585 |
-
)
|
| 586 |
-
|
| 587 |
-
# Specific error messages for common status codes for user display
|
| 588 |
-
if status_code == 401:
|
| 589 |
-
user_display_error_message = "Unauthorized: Please login first to use this node."
|
| 590 |
-
elif status_code == 402:
|
| 591 |
-
user_display_error_message = "Payment Required: Please add credits to your account to use this node."
|
| 592 |
-
elif status_code == 409:
|
| 593 |
-
user_display_error_message = "There is a problem with your account. Please contact support@comfy.org."
|
| 594 |
-
elif status_code == 429:
|
| 595 |
-
user_display_error_message = "Rate Limit Exceeded: Please try again later."
|
| 596 |
-
# else, user_display_error_message remains as parsed from response or original HTTPError string
|
| 597 |
-
|
| 598 |
-
raise Exception(user_display_error_message) # Raise with the user-friendly message
|
| 599 |
-
|
| 600 |
-
# Parse and return JSON response
|
| 601 |
-
if response.content:
|
| 602 |
-
return response.json()
|
| 603 |
-
return {}
|
| 604 |
-
|
| 605 |
-
def check_auth(self, auth_token, comfy_api_key):
|
| 606 |
-
"""Verify that an auth token is present or comfy_api_key is present"""
|
| 607 |
-
if auth_token is None and comfy_api_key is None:
|
| 608 |
-
raise Exception("Unauthorized: Please login first to use this node.")
|
| 609 |
-
return auth_token or comfy_api_key
|
| 610 |
-
|
| 611 |
-
@staticmethod
|
| 612 |
-
def upload_file(
|
| 613 |
-
upload_url: str,
|
| 614 |
-
file: io.BytesIO | str,
|
| 615 |
-
content_type: str | None = None,
|
| 616 |
-
max_retries: int = 3,
|
| 617 |
-
retry_delay: float = 1.0,
|
| 618 |
-
retry_backoff_factor: float = 2.0,
|
| 619 |
-
):
|
| 620 |
-
"""Upload a file to the API with retry logic.
|
| 621 |
-
|
| 622 |
-
Args:
|
| 623 |
-
upload_url: The URL to upload to
|
| 624 |
-
file: Either a file path string, BytesIO object, or tuple of (file_path, filename)
|
| 625 |
-
content_type: Optional mime type to set for the upload
|
| 626 |
-
max_retries: Maximum number of retry attempts
|
| 627 |
-
retry_delay: Initial delay between retries in seconds
|
| 628 |
-
retry_backoff_factor: Multiplier for the delay after each retry
|
| 629 |
-
"""
|
| 630 |
-
headers = {}
|
| 631 |
-
if content_type:
|
| 632 |
-
headers["Content-Type"] = content_type
|
| 633 |
-
|
| 634 |
-
# Prepare the file data
|
| 635 |
-
if isinstance(file, io.BytesIO):
|
| 636 |
-
file.seek(0) # Ensure we're at the start of the file
|
| 637 |
-
data = file.read()
|
| 638 |
-
elif isinstance(file, str):
|
| 639 |
-
with open(file, "rb") as f:
|
| 640 |
-
data = f.read()
|
| 641 |
-
else:
|
| 642 |
-
raise ValueError("File must be either a BytesIO object or a file path string")
|
| 643 |
-
|
| 644 |
-
# Try the upload with retries
|
| 645 |
-
last_exception = None
|
| 646 |
-
operation_id = f"upload_{upload_url.split('/')[-1]}_{uuid.uuid4().hex[:8]}" # Simplified ID for uploads
|
| 647 |
-
|
| 648 |
-
# Log initial attempt (without full file data for brevity)
|
| 649 |
-
request_logger.log_request_response(
|
| 650 |
-
operation_id=operation_id,
|
| 651 |
-
request_method="PUT",
|
| 652 |
-
request_url=upload_url,
|
| 653 |
-
request_headers=headers,
|
| 654 |
-
request_data=f"[File data of type {content_type or 'unknown'}, size {len(data)} bytes]"
|
| 655 |
-
)
|
| 656 |
-
|
| 657 |
-
for retry_attempt in range(max_retries + 1):
|
| 658 |
-
try:
|
| 659 |
-
response = requests.put(upload_url, data=data, headers=headers)
|
| 660 |
-
response.raise_for_status()
|
| 661 |
-
request_logger.log_request_response(
|
| 662 |
-
operation_id=operation_id,
|
| 663 |
-
request_method="PUT", request_url=upload_url, # For context
|
| 664 |
-
response_status_code=response.status_code,
|
| 665 |
-
response_headers=dict(response.headers),
|
| 666 |
-
response_content="File uploaded successfully." # Or response.text if available
|
| 667 |
-
)
|
| 668 |
-
return response
|
| 669 |
-
|
| 670 |
-
except (requests.ConnectionError, requests.Timeout, requests.HTTPError) as e:
|
| 671 |
-
last_exception = e
|
| 672 |
-
error_message_for_log = f"{type(e).__name__}: {str(e)}"
|
| 673 |
-
response_content_for_log = None
|
| 674 |
-
status_code_for_log = None
|
| 675 |
-
headers_for_log = None
|
| 676 |
-
|
| 677 |
-
if hasattr(e, 'response') and e.response is not None:
|
| 678 |
-
status_code_for_log = e.response.status_code
|
| 679 |
-
headers_for_log = dict(e.response.headers)
|
| 680 |
-
try:
|
| 681 |
-
response_content_for_log = e.response.json()
|
| 682 |
-
except json.JSONDecodeError:
|
| 683 |
-
response_content_for_log = e.response.content
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
request_logger.log_request_response(
|
| 687 |
-
operation_id=operation_id,
|
| 688 |
-
request_method="PUT", request_url=upload_url,
|
| 689 |
-
response_status_code=status_code_for_log,
|
| 690 |
-
response_headers=headers_for_log,
|
| 691 |
-
response_content=response_content_for_log,
|
| 692 |
-
error_message=error_message_for_log
|
| 693 |
-
)
|
| 694 |
-
|
| 695 |
-
if retry_attempt < max_retries:
|
| 696 |
-
delay = retry_delay * (retry_backoff_factor ** retry_attempt)
|
| 697 |
-
logging.warning(
|
| 698 |
-
f"File upload failed: {str(e)}. "
|
| 699 |
-
f"Retrying in {delay:.2f}s ({retry_attempt + 1}/{max_retries})"
|
| 700 |
-
)
|
| 701 |
-
time.sleep(delay)
|
| 702 |
-
else:
|
| 703 |
-
break # Max retries reached
|
| 704 |
-
|
| 705 |
-
# If we've exhausted all retries, determine the final error type and raise
|
| 706 |
-
final_error_message = f"Failed to upload file after {max_retries + 1} attempts. Error: {str(last_exception)}"
|
| 707 |
-
try:
|
| 708 |
-
# Check basic internet connectivity
|
| 709 |
-
check_response = requests.get("https://www.google.com", timeout=5.0, verify=True) # Assuming verify=True is desired
|
| 710 |
-
if check_response.status_code >= 500: # Google itself has an issue (rare)
|
| 711 |
-
final_error_message = (f"Failed to upload file. Internet connectivity check to Google failed "
|
| 712 |
-
f"(status {check_response.status_code}). Original error: {str(last_exception)}")
|
| 713 |
-
# Not raising LocalNetworkError here as Google itself might be down.
|
| 714 |
-
# If Google is reachable, the issue is likely with the upload server or a more specific local problem
|
| 715 |
-
# not caught by a simple Google ping (e.g., DNS for the specific upload URL, firewall).
|
| 716 |
-
# The original last_exception is probably most relevant.
|
| 717 |
-
|
| 718 |
-
except (requests.RequestException, socket.error) as conn_check_exc:
|
| 719 |
-
# Could not reach Google, likely a local network issue
|
| 720 |
-
final_error_message = (f"Failed to upload file due to network connectivity issues "
|
| 721 |
-
f"(cannot reach Google: {str(conn_check_exc)}). "
|
| 722 |
-
f"Original upload error: {str(last_exception)}")
|
| 723 |
-
request_logger.log_request_response( # Log final failure reason
|
| 724 |
-
operation_id=operation_id,
|
| 725 |
-
request_method="PUT", request_url=upload_url,
|
| 726 |
-
error_message=final_error_message
|
| 727 |
-
)
|
| 728 |
-
raise LocalNetworkError(final_error_message) from last_exception
|
| 729 |
-
|
| 730 |
-
request_logger.log_request_response( # Log final failure reason if not LocalNetworkError
|
| 731 |
-
operation_id=operation_id,
|
| 732 |
-
request_method="PUT", request_url=upload_url,
|
| 733 |
-
error_message=final_error_message
|
| 734 |
-
)
|
| 735 |
-
raise Exception(final_error_message) from last_exception
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
class ApiEndpoint(Generic[T, R]):
|
| 739 |
-
"""Defines an API endpoint with its request and response types"""
|
| 740 |
-
|
| 741 |
-
def __init__(
|
| 742 |
-
self,
|
| 743 |
-
path: str,
|
| 744 |
-
method: HttpMethod,
|
| 745 |
-
request_model: Type[T],
|
| 746 |
-
response_model: Type[R],
|
| 747 |
-
query_params: Optional[Dict[str, Any]] = None,
|
| 748 |
-
):
|
| 749 |
-
"""Initialize an API endpoint definition.
|
| 750 |
-
|
| 751 |
-
Args:
|
| 752 |
-
path: The URL path for this endpoint, can include placeholders like {id}
|
| 753 |
-
method: The HTTP method to use (GET, POST, etc.)
|
| 754 |
-
request_model: Pydantic model class that defines the structure and validation rules for API requests to this endpoint
|
| 755 |
-
response_model: Pydantic model class that defines the structure and validation rules for API responses from this endpoint
|
| 756 |
-
query_params: Optional dictionary of query parameters to include in the request
|
| 757 |
-
"""
|
| 758 |
-
self.path = path
|
| 759 |
-
self.method = method
|
| 760 |
-
self.request_model = request_model
|
| 761 |
-
self.response_model = response_model
|
| 762 |
-
self.query_params = query_params or {}
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
class SynchronousOperation(Generic[T, R]):
|
| 766 |
-
"""
|
| 767 |
-
Represents a single synchronous API operation.
|
| 768 |
-
"""
|
| 769 |
-
|
| 770 |
-
def __init__(
|
| 771 |
-
self,
|
| 772 |
-
endpoint: ApiEndpoint[T, R],
|
| 773 |
-
request: T,
|
| 774 |
-
files: Optional[Dict[str, Any]] = None,
|
| 775 |
-
api_base: str | None = None,
|
| 776 |
-
auth_token: Optional[str] = None,
|
| 777 |
-
comfy_api_key: Optional[str] = None,
|
| 778 |
-
auth_kwargs: Optional[Dict[str,str]] = None,
|
| 779 |
-
timeout: float = 604800.0,
|
| 780 |
-
verify_ssl: bool = True,
|
| 781 |
-
content_type: str = "application/json",
|
| 782 |
-
multipart_parser: Callable = None,
|
| 783 |
-
max_retries: int = 3,
|
| 784 |
-
retry_delay: float = 1.0,
|
| 785 |
-
retry_backoff_factor: float = 2.0,
|
| 786 |
-
):
|
| 787 |
-
self.endpoint = endpoint
|
| 788 |
-
self.request = request
|
| 789 |
-
self.response = None
|
| 790 |
-
self.error = None
|
| 791 |
-
self.api_base: str = api_base or args.comfy_api_base
|
| 792 |
-
self.auth_token = auth_token
|
| 793 |
-
self.comfy_api_key = comfy_api_key
|
| 794 |
-
if auth_kwargs is not None:
|
| 795 |
-
self.auth_token = auth_kwargs.get("auth_token", self.auth_token)
|
| 796 |
-
self.comfy_api_key = auth_kwargs.get("comfy_api_key", self.comfy_api_key)
|
| 797 |
-
self.timeout = timeout
|
| 798 |
-
self.verify_ssl = verify_ssl
|
| 799 |
-
self.files = files
|
| 800 |
-
self.content_type = content_type
|
| 801 |
-
self.multipart_parser = multipart_parser
|
| 802 |
-
self.max_retries = max_retries
|
| 803 |
-
self.retry_delay = retry_delay
|
| 804 |
-
self.retry_backoff_factor = retry_backoff_factor
|
| 805 |
-
|
| 806 |
-
def execute(self, client: Optional[ApiClient] = None) -> R:
|
| 807 |
-
"""Execute the API operation using the provided client or create one with retry support"""
|
| 808 |
-
try:
|
| 809 |
-
# Create client if not provided
|
| 810 |
-
if client is None:
|
| 811 |
-
client = ApiClient(
|
| 812 |
-
base_url=self.api_base,
|
| 813 |
-
auth_token=self.auth_token,
|
| 814 |
-
comfy_api_key=self.comfy_api_key,
|
| 815 |
-
timeout=self.timeout,
|
| 816 |
-
verify_ssl=self.verify_ssl,
|
| 817 |
-
max_retries=self.max_retries,
|
| 818 |
-
retry_delay=self.retry_delay,
|
| 819 |
-
retry_backoff_factor=self.retry_backoff_factor,
|
| 820 |
-
)
|
| 821 |
-
|
| 822 |
-
# Convert request model to dict, but use None for EmptyRequest
|
| 823 |
-
request_dict = (
|
| 824 |
-
None
|
| 825 |
-
if isinstance(self.request, EmptyRequest)
|
| 826 |
-
else self.request.model_dump(exclude_none=True)
|
| 827 |
-
)
|
| 828 |
-
if request_dict:
|
| 829 |
-
for key, value in request_dict.items():
|
| 830 |
-
if isinstance(value, Enum):
|
| 831 |
-
request_dict[key] = value.value
|
| 832 |
-
|
| 833 |
-
# Debug log for request
|
| 834 |
-
logging.debug(
|
| 835 |
-
f"[DEBUG] API Request: {self.endpoint.method.value} {self.endpoint.path}"
|
| 836 |
-
)
|
| 837 |
-
logging.debug(f"[DEBUG] Request Data: {json.dumps(request_dict, indent=2)}")
|
| 838 |
-
logging.debug(f"[DEBUG] Query Params: {self.endpoint.query_params}")
|
| 839 |
-
|
| 840 |
-
# Make the request with built-in retry
|
| 841 |
-
resp = client.request(
|
| 842 |
-
method=self.endpoint.method.value,
|
| 843 |
-
path=self.endpoint.path,
|
| 844 |
-
data=request_dict,
|
| 845 |
-
params=self.endpoint.query_params,
|
| 846 |
-
files=self.files,
|
| 847 |
-
content_type=self.content_type,
|
| 848 |
-
multipart_parser=self.multipart_parser
|
| 849 |
-
)
|
| 850 |
-
|
| 851 |
-
# Debug log for response
|
| 852 |
-
logging.debug("=" * 50)
|
| 853 |
-
logging.debug("[DEBUG] RESPONSE DETAILS:")
|
| 854 |
-
logging.debug("[DEBUG] Status Code: 200 (Success)")
|
| 855 |
-
logging.debug(f"[DEBUG] Response Body: {json.dumps(resp, indent=2)}")
|
| 856 |
-
logging.debug("=" * 50)
|
| 857 |
-
|
| 858 |
-
# Parse and return the response
|
| 859 |
-
return self._parse_response(resp)
|
| 860 |
-
|
| 861 |
-
except LocalNetworkError as e:
|
| 862 |
-
# Propagate specific network error types
|
| 863 |
-
logging.error(f"[ERROR] Local network error: {str(e)}")
|
| 864 |
-
raise
|
| 865 |
-
|
| 866 |
-
except ApiServerError as e:
|
| 867 |
-
# Propagate API server errors
|
| 868 |
-
logging.error(f"[ERROR] API server error: {str(e)}")
|
| 869 |
-
raise
|
| 870 |
-
|
| 871 |
-
except Exception as e:
|
| 872 |
-
logging.error(f"[ERROR] API Exception: {str(e)}")
|
| 873 |
-
raise Exception(str(e))
|
| 874 |
-
|
| 875 |
-
def _parse_response(self, resp):
|
| 876 |
-
"""Parse response data - can be overridden by subclasses"""
|
| 877 |
-
# The response is already the complete object, don't extract just the "data" field
|
| 878 |
-
# as that would lose the outer structure (created timestamp, etc.)
|
| 879 |
-
|
| 880 |
-
# Parse response using the provided model
|
| 881 |
-
self.response = self.endpoint.response_model.model_validate(resp)
|
| 882 |
-
logging.debug(f"[DEBUG] Parsed Response: {self.response}")
|
| 883 |
-
return self.response
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
class TaskStatus(str, Enum):
|
| 887 |
-
"""Enum for task status values"""
|
| 888 |
-
|
| 889 |
-
COMPLETED = "completed"
|
| 890 |
-
FAILED = "failed"
|
| 891 |
-
PENDING = "pending"
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
class PollingOperation(Generic[T, R]):
|
| 895 |
-
"""
|
| 896 |
-
Represents an asynchronous API operation that requires polling for completion.
|
| 897 |
-
"""
|
| 898 |
-
|
| 899 |
-
def __init__(
|
| 900 |
-
self,
|
| 901 |
-
poll_endpoint: ApiEndpoint[EmptyRequest, R],
|
| 902 |
-
completed_statuses: list,
|
| 903 |
-
failed_statuses: list,
|
| 904 |
-
status_extractor: Callable[[R], str],
|
| 905 |
-
progress_extractor: Callable[[R], float] = None,
|
| 906 |
-
result_url_extractor: Callable[[R], str] = None,
|
| 907 |
-
request: Optional[T] = None,
|
| 908 |
-
api_base: str | None = None,
|
| 909 |
-
auth_token: Optional[str] = None,
|
| 910 |
-
comfy_api_key: Optional[str] = None,
|
| 911 |
-
auth_kwargs: Optional[Dict[str,str]] = None,
|
| 912 |
-
poll_interval: float = 5.0,
|
| 913 |
-
max_poll_attempts: int = 120, # Default max polling attempts (10 minutes with 5s interval)
|
| 914 |
-
max_retries: int = 3, # Max retries per individual API call
|
| 915 |
-
retry_delay: float = 1.0,
|
| 916 |
-
retry_backoff_factor: float = 2.0,
|
| 917 |
-
estimated_duration: Optional[float] = None,
|
| 918 |
-
node_id: Optional[str] = None,
|
| 919 |
-
):
|
| 920 |
-
self.poll_endpoint = poll_endpoint
|
| 921 |
-
self.request = request
|
| 922 |
-
self.api_base: str = api_base or args.comfy_api_base
|
| 923 |
-
self.auth_token = auth_token
|
| 924 |
-
self.comfy_api_key = comfy_api_key
|
| 925 |
-
if auth_kwargs is not None:
|
| 926 |
-
self.auth_token = auth_kwargs.get("auth_token", self.auth_token)
|
| 927 |
-
self.comfy_api_key = auth_kwargs.get("comfy_api_key", self.comfy_api_key)
|
| 928 |
-
self.poll_interval = poll_interval
|
| 929 |
-
self.max_poll_attempts = max_poll_attempts
|
| 930 |
-
self.max_retries = max_retries
|
| 931 |
-
self.retry_delay = retry_delay
|
| 932 |
-
self.retry_backoff_factor = retry_backoff_factor
|
| 933 |
-
self.estimated_duration = estimated_duration
|
| 934 |
-
|
| 935 |
-
# Polling configuration
|
| 936 |
-
self.status_extractor = status_extractor or (
|
| 937 |
-
lambda x: getattr(x, "status", None)
|
| 938 |
-
)
|
| 939 |
-
self.progress_extractor = progress_extractor
|
| 940 |
-
self.result_url_extractor = result_url_extractor
|
| 941 |
-
self.node_id = node_id
|
| 942 |
-
self.completed_statuses = completed_statuses
|
| 943 |
-
self.failed_statuses = failed_statuses
|
| 944 |
-
|
| 945 |
-
# For storing response data
|
| 946 |
-
self.final_response = None
|
| 947 |
-
self.error = None
|
| 948 |
-
|
| 949 |
-
def execute(self, client: Optional[ApiClient] = None) -> R:
|
| 950 |
-
"""Execute the polling operation using the provided client. If failed, raise an exception."""
|
| 951 |
-
try:
|
| 952 |
-
if client is None:
|
| 953 |
-
client = ApiClient(
|
| 954 |
-
base_url=self.api_base,
|
| 955 |
-
auth_token=self.auth_token,
|
| 956 |
-
comfy_api_key=self.comfy_api_key,
|
| 957 |
-
max_retries=self.max_retries,
|
| 958 |
-
retry_delay=self.retry_delay,
|
| 959 |
-
retry_backoff_factor=self.retry_backoff_factor,
|
| 960 |
-
)
|
| 961 |
-
return self._poll_until_complete(client)
|
| 962 |
-
except LocalNetworkError as e:
|
| 963 |
-
# Provide clear message for local network issues
|
| 964 |
-
raise Exception(
|
| 965 |
-
f"Polling failed due to local network issues. Please check your internet connection. "
|
| 966 |
-
f"Details: {str(e)}"
|
| 967 |
-
) from e
|
| 968 |
-
except ApiServerError as e:
|
| 969 |
-
# Provide clear message for API server issues
|
| 970 |
-
raise Exception(
|
| 971 |
-
f"Polling failed due to API server issues. The service may be experiencing problems. "
|
| 972 |
-
f"Please try again later. Details: {str(e)}"
|
| 973 |
-
) from e
|
| 974 |
-
except Exception as e:
|
| 975 |
-
raise Exception(f"Error during polling: {str(e)}")
|
| 976 |
-
|
| 977 |
-
def _display_text_on_node(self, text: str):
|
| 978 |
-
"""Sends text to the client which will be displayed on the node in the UI"""
|
| 979 |
-
if not self.node_id:
|
| 980 |
-
return
|
| 981 |
-
|
| 982 |
-
PromptServer.instance.send_progress_text(text, self.node_id)
|
| 983 |
-
|
| 984 |
-
def _display_time_progress_on_node(self, time_completed: int):
|
| 985 |
-
if not self.node_id:
|
| 986 |
-
return
|
| 987 |
-
|
| 988 |
-
if self.estimated_duration is not None:
|
| 989 |
-
estimated_time_remaining = max(
|
| 990 |
-
0, int(self.estimated_duration) - int(time_completed)
|
| 991 |
-
)
|
| 992 |
-
message = f"Task in progress: {time_completed:.0f}s (~{estimated_time_remaining:.0f}s remaining)"
|
| 993 |
-
else:
|
| 994 |
-
message = f"Task in progress: {time_completed:.0f}s"
|
| 995 |
-
self._display_text_on_node(message)
|
| 996 |
-
|
| 997 |
-
def _check_task_status(self, response: R) -> TaskStatus:
|
| 998 |
-
"""Check task status using the status extractor function"""
|
| 999 |
-
try:
|
| 1000 |
-
status = self.status_extractor(response)
|
| 1001 |
-
if status in self.completed_statuses:
|
| 1002 |
-
return TaskStatus.COMPLETED
|
| 1003 |
-
elif status in self.failed_statuses:
|
| 1004 |
-
return TaskStatus.FAILED
|
| 1005 |
-
return TaskStatus.PENDING
|
| 1006 |
-
except Exception as e:
|
| 1007 |
-
logging.error(f"Error extracting status: {e}")
|
| 1008 |
-
return TaskStatus.PENDING
|
| 1009 |
-
|
| 1010 |
-
def _poll_until_complete(self, client: ApiClient) -> R:
|
| 1011 |
-
"""Poll until the task is complete"""
|
| 1012 |
-
poll_count = 0
|
| 1013 |
-
consecutive_errors = 0
|
| 1014 |
-
max_consecutive_errors = min(5, self.max_retries * 2) # Limit consecutive errors
|
| 1015 |
-
|
| 1016 |
-
if self.progress_extractor:
|
| 1017 |
-
progress = utils.ProgressBar(PROGRESS_BAR_MAX)
|
| 1018 |
-
|
| 1019 |
-
while poll_count < self.max_poll_attempts:
|
| 1020 |
-
try:
|
| 1021 |
-
poll_count += 1
|
| 1022 |
-
logging.debug(f"[DEBUG] Polling attempt #{poll_count}")
|
| 1023 |
-
|
| 1024 |
-
request_dict = (
|
| 1025 |
-
self.request.model_dump(exclude_none=True)
|
| 1026 |
-
if self.request is not None
|
| 1027 |
-
else None
|
| 1028 |
-
)
|
| 1029 |
-
|
| 1030 |
-
if poll_count == 1:
|
| 1031 |
-
logging.debug(
|
| 1032 |
-
f"[DEBUG] Poll Request: {self.poll_endpoint.method.value} {self.poll_endpoint.path}"
|
| 1033 |
-
)
|
| 1034 |
-
logging.debug(
|
| 1035 |
-
f"[DEBUG] Poll Request Data: {json.dumps(request_dict, indent=2) if request_dict else 'None'}"
|
| 1036 |
-
)
|
| 1037 |
-
|
| 1038 |
-
# Query task status
|
| 1039 |
-
resp = client.request(
|
| 1040 |
-
method=self.poll_endpoint.method.value,
|
| 1041 |
-
path=self.poll_endpoint.path,
|
| 1042 |
-
params=self.poll_endpoint.query_params,
|
| 1043 |
-
data=request_dict,
|
| 1044 |
-
)
|
| 1045 |
-
|
| 1046 |
-
# Successfully got a response, reset consecutive error count
|
| 1047 |
-
consecutive_errors = 0
|
| 1048 |
-
|
| 1049 |
-
# Parse response
|
| 1050 |
-
response_obj = self.poll_endpoint.response_model.model_validate(resp)
|
| 1051 |
-
|
| 1052 |
-
# Check if task is complete
|
| 1053 |
-
status = self._check_task_status(response_obj)
|
| 1054 |
-
logging.debug(f"[DEBUG] Task Status: {status}")
|
| 1055 |
-
|
| 1056 |
-
# If progress extractor is provided, extract progress
|
| 1057 |
-
if self.progress_extractor:
|
| 1058 |
-
new_progress = self.progress_extractor(response_obj)
|
| 1059 |
-
if new_progress is not None:
|
| 1060 |
-
progress.update_absolute(new_progress, total=PROGRESS_BAR_MAX)
|
| 1061 |
-
|
| 1062 |
-
if status == TaskStatus.COMPLETED:
|
| 1063 |
-
message = "Task completed successfully"
|
| 1064 |
-
if self.result_url_extractor:
|
| 1065 |
-
result_url = self.result_url_extractor(response_obj)
|
| 1066 |
-
if result_url:
|
| 1067 |
-
message = f"Result URL: {result_url}"
|
| 1068 |
-
else:
|
| 1069 |
-
message = "Task completed successfully!"
|
| 1070 |
-
logging.debug(f"[DEBUG] {message}")
|
| 1071 |
-
self._display_text_on_node(message)
|
| 1072 |
-
self.final_response = response_obj
|
| 1073 |
-
if self.progress_extractor:
|
| 1074 |
-
progress.update(100)
|
| 1075 |
-
return self.final_response
|
| 1076 |
-
elif status == TaskStatus.FAILED:
|
| 1077 |
-
message = f"Task failed: {json.dumps(resp)}"
|
| 1078 |
-
logging.error(f"[DEBUG] {message}")
|
| 1079 |
-
raise Exception(message)
|
| 1080 |
-
else:
|
| 1081 |
-
logging.debug("[DEBUG] Task still pending, continuing to poll...")
|
| 1082 |
-
|
| 1083 |
-
# Wait before polling again
|
| 1084 |
-
logging.debug(
|
| 1085 |
-
f"[DEBUG] Waiting {self.poll_interval} seconds before next poll"
|
| 1086 |
-
)
|
| 1087 |
-
for i in range(int(self.poll_interval)):
|
| 1088 |
-
time_completed = (poll_count * self.poll_interval) + i
|
| 1089 |
-
self._display_time_progress_on_node(time_completed)
|
| 1090 |
-
time.sleep(1)
|
| 1091 |
-
|
| 1092 |
-
except (LocalNetworkError, ApiServerError) as e:
|
| 1093 |
-
# For network-related errors, increment error count and potentially abort
|
| 1094 |
-
consecutive_errors += 1
|
| 1095 |
-
if consecutive_errors >= max_consecutive_errors:
|
| 1096 |
-
raise Exception(
|
| 1097 |
-
f"Polling aborted after {consecutive_errors} consecutive network errors: {str(e)}"
|
| 1098 |
-
) from e
|
| 1099 |
-
|
| 1100 |
-
# Log the error but continue polling
|
| 1101 |
-
logging.warning(
|
| 1102 |
-
f"Network error during polling (attempt {poll_count}/{self.max_poll_attempts}): {str(e)}. "
|
| 1103 |
-
f"Will retry in {self.poll_interval} seconds."
|
| 1104 |
-
)
|
| 1105 |
-
time.sleep(self.poll_interval)
|
| 1106 |
-
|
| 1107 |
-
except Exception as e:
|
| 1108 |
-
# For other errors, increment count and potentially abort
|
| 1109 |
-
consecutive_errors += 1
|
| 1110 |
-
if consecutive_errors >= max_consecutive_errors or status == TaskStatus.FAILED:
|
| 1111 |
-
raise Exception(
|
| 1112 |
-
f"Polling aborted after {consecutive_errors} consecutive errors: {str(e)}"
|
| 1113 |
-
) from e
|
| 1114 |
-
|
| 1115 |
-
logging.error(f"[DEBUG] Polling error: {str(e)}")
|
| 1116 |
-
logging.warning(
|
| 1117 |
-
f"Error during polling (attempt {poll_count}/{self.max_poll_attempts}): {str(e)}. "
|
| 1118 |
-
f"Will retry in {self.poll_interval} seconds."
|
| 1119 |
-
)
|
| 1120 |
-
time.sleep(self.poll_interval)
|
| 1121 |
-
|
| 1122 |
-
# If we've exhausted all polling attempts
|
| 1123 |
-
raise Exception(
|
| 1124 |
-
f"Polling timed out after {poll_count} attempts ({poll_count * self.poll_interval} seconds). "
|
| 1125 |
-
f"The operation may still be running on the server but is taking longer than expected."
|
| 1126 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/luma_api.py
DELETED
|
@@ -1,253 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
import torch
|
| 5 |
-
|
| 6 |
-
from enum import Enum
|
| 7 |
-
from typing import Optional, Union
|
| 8 |
-
|
| 9 |
-
from pydantic import BaseModel, Field, confloat
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
class LumaIO:
|
| 14 |
-
LUMA_REF = "LUMA_REF"
|
| 15 |
-
LUMA_CONCEPTS = "LUMA_CONCEPTS"
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
class LumaReference:
|
| 19 |
-
def __init__(self, image: torch.Tensor, weight: float):
|
| 20 |
-
self.image = image
|
| 21 |
-
self.weight = weight
|
| 22 |
-
|
| 23 |
-
def create_api_model(self, download_url: str):
|
| 24 |
-
return LumaImageRef(url=download_url, weight=self.weight)
|
| 25 |
-
|
| 26 |
-
class LumaReferenceChain:
|
| 27 |
-
def __init__(self, first_ref: LumaReference=None):
|
| 28 |
-
self.refs: list[LumaReference] = []
|
| 29 |
-
if first_ref:
|
| 30 |
-
self.refs.append(first_ref)
|
| 31 |
-
|
| 32 |
-
def add(self, luma_ref: LumaReference=None):
|
| 33 |
-
self.refs.append(luma_ref)
|
| 34 |
-
|
| 35 |
-
def create_api_model(self, download_urls: list[str], max_refs=4):
|
| 36 |
-
if len(self.refs) == 0:
|
| 37 |
-
return None
|
| 38 |
-
api_refs: list[LumaImageRef] = []
|
| 39 |
-
for ref, url in zip(self.refs, download_urls):
|
| 40 |
-
api_ref = LumaImageRef(url=url, weight=ref.weight)
|
| 41 |
-
api_refs.append(api_ref)
|
| 42 |
-
return api_refs
|
| 43 |
-
|
| 44 |
-
def clone(self):
|
| 45 |
-
c = LumaReferenceChain()
|
| 46 |
-
for ref in self.refs:
|
| 47 |
-
c.add(ref)
|
| 48 |
-
return c
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
class LumaConcept:
|
| 52 |
-
def __init__(self, key: str):
|
| 53 |
-
self.key = key
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
class LumaConceptChain:
|
| 57 |
-
def __init__(self, str_list: list[str] = None):
|
| 58 |
-
self.concepts: list[LumaConcept] = []
|
| 59 |
-
if str_list is not None:
|
| 60 |
-
for c in str_list:
|
| 61 |
-
if c != "None":
|
| 62 |
-
self.add(LumaConcept(key=c))
|
| 63 |
-
|
| 64 |
-
def add(self, concept: LumaConcept):
|
| 65 |
-
self.concepts.append(concept)
|
| 66 |
-
|
| 67 |
-
def create_api_model(self):
|
| 68 |
-
if len(self.concepts) == 0:
|
| 69 |
-
return None
|
| 70 |
-
api_concepts: list[LumaConceptObject] = []
|
| 71 |
-
for concept in self.concepts:
|
| 72 |
-
if concept.key == "None":
|
| 73 |
-
continue
|
| 74 |
-
api_concepts.append(LumaConceptObject(key=concept.key))
|
| 75 |
-
if len(api_concepts) == 0:
|
| 76 |
-
return None
|
| 77 |
-
return api_concepts
|
| 78 |
-
|
| 79 |
-
def clone(self):
|
| 80 |
-
c = LumaConceptChain()
|
| 81 |
-
for concept in self.concepts:
|
| 82 |
-
c.add(concept)
|
| 83 |
-
return c
|
| 84 |
-
|
| 85 |
-
def clone_and_merge(self, other: LumaConceptChain):
|
| 86 |
-
c = self.clone()
|
| 87 |
-
for concept in other.concepts:
|
| 88 |
-
c.add(concept)
|
| 89 |
-
return c
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def get_luma_concepts(include_none=False):
|
| 93 |
-
concepts = []
|
| 94 |
-
if include_none:
|
| 95 |
-
concepts.append("None")
|
| 96 |
-
return concepts + [
|
| 97 |
-
"truck_left",
|
| 98 |
-
"pan_right",
|
| 99 |
-
"pedestal_down",
|
| 100 |
-
"low_angle",
|
| 101 |
-
"pedestal_up",
|
| 102 |
-
"selfie",
|
| 103 |
-
"pan_left",
|
| 104 |
-
"roll_right",
|
| 105 |
-
"zoom_in",
|
| 106 |
-
"over_the_shoulder",
|
| 107 |
-
"orbit_right",
|
| 108 |
-
"orbit_left",
|
| 109 |
-
"static",
|
| 110 |
-
"tiny_planet",
|
| 111 |
-
"high_angle",
|
| 112 |
-
"bolt_cam",
|
| 113 |
-
"dolly_zoom",
|
| 114 |
-
"overhead",
|
| 115 |
-
"zoom_out",
|
| 116 |
-
"handheld",
|
| 117 |
-
"roll_left",
|
| 118 |
-
"pov",
|
| 119 |
-
"aerial_drone",
|
| 120 |
-
"push_in",
|
| 121 |
-
"crane_down",
|
| 122 |
-
"truck_right",
|
| 123 |
-
"tilt_down",
|
| 124 |
-
"elevator_doors",
|
| 125 |
-
"tilt_up",
|
| 126 |
-
"ground_level",
|
| 127 |
-
"pull_out",
|
| 128 |
-
"aerial",
|
| 129 |
-
"crane_up",
|
| 130 |
-
"eye_level"
|
| 131 |
-
]
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
class LumaImageModel(str, Enum):
|
| 135 |
-
photon_1 = "photon-1"
|
| 136 |
-
photon_flash_1 = "photon-flash-1"
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
class LumaVideoModel(str, Enum):
|
| 140 |
-
ray_2 = "ray-2"
|
| 141 |
-
ray_flash_2 = "ray-flash-2"
|
| 142 |
-
ray_1_6 = "ray-1-6"
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
class LumaAspectRatio(str, Enum):
|
| 146 |
-
ratio_1_1 = "1:1"
|
| 147 |
-
ratio_16_9 = "16:9"
|
| 148 |
-
ratio_9_16 = "9:16"
|
| 149 |
-
ratio_4_3 = "4:3"
|
| 150 |
-
ratio_3_4 = "3:4"
|
| 151 |
-
ratio_21_9 = "21:9"
|
| 152 |
-
ratio_9_21 = "9:21"
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
class LumaVideoOutputResolution(str, Enum):
|
| 156 |
-
res_540p = "540p"
|
| 157 |
-
res_720p = "720p"
|
| 158 |
-
res_1080p = "1080p"
|
| 159 |
-
res_4k = "4k"
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
class LumaVideoModelOutputDuration(str, Enum):
|
| 163 |
-
dur_5s = "5s"
|
| 164 |
-
dur_9s = "9s"
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
class LumaGenerationType(str, Enum):
|
| 168 |
-
video = 'video'
|
| 169 |
-
image = 'image'
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
class LumaState(str, Enum):
|
| 173 |
-
queued = "queued"
|
| 174 |
-
dreaming = "dreaming"
|
| 175 |
-
completed = "completed"
|
| 176 |
-
failed = "failed"
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
class LumaAssets(BaseModel):
|
| 180 |
-
video: Optional[str] = Field(None, description='The URL of the video')
|
| 181 |
-
image: Optional[str] = Field(None, description='The URL of the image')
|
| 182 |
-
progress_video: Optional[str] = Field(None, description='The URL of the progress video')
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
class LumaImageRef(BaseModel):
|
| 186 |
-
'''Used for image gen'''
|
| 187 |
-
url: str = Field(..., description='The URL of the image reference')
|
| 188 |
-
weight: confloat(ge=0.0, le=1.0) = Field(..., description='The weight of the image reference')
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
class LumaImageReference(BaseModel):
|
| 192 |
-
'''Used for video gen'''
|
| 193 |
-
type: Optional[str] = Field('image', description='Input type, defaults to image')
|
| 194 |
-
url: str = Field(..., description='The URL of the image')
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
class LumaModifyImageRef(BaseModel):
|
| 198 |
-
url: str = Field(..., description='The URL of the image reference')
|
| 199 |
-
weight: confloat(ge=0.0, le=1.0) = Field(..., description='The weight of the image reference')
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
class LumaCharacterRef(BaseModel):
|
| 203 |
-
identity0: LumaImageIdentity = Field(..., description='The image identity object')
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
class LumaImageIdentity(BaseModel):
|
| 207 |
-
images: list[str] = Field(..., description='The URLs of the image identity')
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
class LumaGenerationReference(BaseModel):
|
| 211 |
-
type: str = Field('generation', description='Input type, defaults to generation')
|
| 212 |
-
id: str = Field(..., description='The ID of the generation')
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
class LumaKeyframes(BaseModel):
|
| 216 |
-
frame0: Optional[Union[LumaImageReference, LumaGenerationReference]] = Field(None, description='')
|
| 217 |
-
frame1: Optional[Union[LumaImageReference, LumaGenerationReference]] = Field(None, description='')
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
class LumaConceptObject(BaseModel):
|
| 221 |
-
key: str = Field(..., description='Camera Concept name')
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
class LumaImageGenerationRequest(BaseModel):
|
| 225 |
-
prompt: str = Field(..., description='The prompt of the generation')
|
| 226 |
-
model: LumaImageModel = Field(LumaImageModel.photon_1, description='The image model used for the generation')
|
| 227 |
-
aspect_ratio: Optional[LumaAspectRatio] = Field(LumaAspectRatio.ratio_16_9, description='The aspect ratio of the generation')
|
| 228 |
-
image_ref: Optional[list[LumaImageRef]] = Field(None, description='List of image reference objects')
|
| 229 |
-
style_ref: Optional[list[LumaImageRef]] = Field(None, description='List of style reference objects')
|
| 230 |
-
character_ref: Optional[LumaCharacterRef] = Field(None, description='The image identity object')
|
| 231 |
-
modify_image_ref: Optional[LumaModifyImageRef] = Field(None, description='The modify image reference object')
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
class LumaGenerationRequest(BaseModel):
|
| 235 |
-
prompt: str = Field(..., description='The prompt of the generation')
|
| 236 |
-
model: LumaVideoModel = Field(LumaVideoModel.ray_2, description='The video model used for the generation')
|
| 237 |
-
duration: Optional[LumaVideoModelOutputDuration] = Field(None, description='The duration of the generation')
|
| 238 |
-
aspect_ratio: Optional[LumaAspectRatio] = Field(None, description='The aspect ratio of the generation')
|
| 239 |
-
resolution: Optional[LumaVideoOutputResolution] = Field(None, description='The resolution of the generation')
|
| 240 |
-
loop: Optional[bool] = Field(None, description='Whether to loop the video')
|
| 241 |
-
keyframes: Optional[LumaKeyframes] = Field(None, description='The keyframes of the generation')
|
| 242 |
-
concepts: Optional[list[LumaConceptObject]] = Field(None, description='Camera Concepts to apply to generation')
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
class LumaGeneration(BaseModel):
|
| 246 |
-
id: str = Field(..., description='The ID of the generation')
|
| 247 |
-
generation_type: LumaGenerationType = Field(..., description='Generation type, image or video')
|
| 248 |
-
state: LumaState = Field(..., description='The state of the generation')
|
| 249 |
-
failure_reason: Optional[str] = Field(None, description='The reason for the state of the generation')
|
| 250 |
-
created_at: str = Field(..., description='The date and time when the generation was created')
|
| 251 |
-
assets: Optional[LumaAssets] = Field(None, description='The assets of the generation')
|
| 252 |
-
model: str = Field(..., description='The model used for the generation')
|
| 253 |
-
request: Union[LumaGenerationRequest, LumaImageGenerationRequest] = Field(..., description="The request used for the generation")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/pixverse_api.py
DELETED
|
@@ -1,146 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from enum import Enum
|
| 4 |
-
from typing import Optional
|
| 5 |
-
|
| 6 |
-
from pydantic import BaseModel, Field
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
pixverse_templates = {
|
| 10 |
-
"Microwave": 324641385496960,
|
| 11 |
-
"Suit Swagger": 328545151283968,
|
| 12 |
-
"Anything, Robot": 313358700761536,
|
| 13 |
-
"Subject 3 Fever": 327828816843648,
|
| 14 |
-
"kiss kiss": 315446315336768,
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
class PixverseIO:
|
| 19 |
-
TEMPLATE = "PIXVERSE_TEMPLATE"
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
class PixverseStatus(int, Enum):
|
| 23 |
-
successful = 1
|
| 24 |
-
generating = 5
|
| 25 |
-
deleted = 6
|
| 26 |
-
contents_moderation = 7
|
| 27 |
-
failed = 8
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
class PixverseAspectRatio(str, Enum):
|
| 31 |
-
ratio_16_9 = "16:9"
|
| 32 |
-
ratio_4_3 = "4:3"
|
| 33 |
-
ratio_1_1 = "1:1"
|
| 34 |
-
ratio_3_4 = "3:4"
|
| 35 |
-
ratio_9_16 = "9:16"
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
class PixverseQuality(str, Enum):
|
| 39 |
-
res_360p = "360p"
|
| 40 |
-
res_540p = "540p"
|
| 41 |
-
res_720p = "720p"
|
| 42 |
-
res_1080p = "1080p"
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
class PixverseDuration(int, Enum):
|
| 46 |
-
dur_5 = 5
|
| 47 |
-
dur_8 = 8
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
class PixverseMotionMode(str, Enum):
|
| 51 |
-
normal = "normal"
|
| 52 |
-
fast = "fast"
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
class PixverseStyle(str, Enum):
|
| 56 |
-
anime = "anime"
|
| 57 |
-
animation_3d = "3d_animation"
|
| 58 |
-
clay = "clay"
|
| 59 |
-
comic = "comic"
|
| 60 |
-
cyberpunk = "cyberpunk"
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
# NOTE: forgoing descriptions for now in return for dev speed
|
| 64 |
-
class PixverseTextVideoRequest(BaseModel):
|
| 65 |
-
aspect_ratio: PixverseAspectRatio = Field(...)
|
| 66 |
-
quality: PixverseQuality = Field(...)
|
| 67 |
-
duration: PixverseDuration = Field(...)
|
| 68 |
-
model: Optional[str] = Field("v3.5")
|
| 69 |
-
motion_mode: Optional[PixverseMotionMode] = Field(PixverseMotionMode.normal)
|
| 70 |
-
prompt: str = Field(...)
|
| 71 |
-
negative_prompt: Optional[str] = Field(None)
|
| 72 |
-
seed: Optional[int] = Field(None)
|
| 73 |
-
style: Optional[str] = Field(None)
|
| 74 |
-
template_id: Optional[int] = Field(None)
|
| 75 |
-
water_mark: Optional[bool] = Field(None)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
class PixverseImageVideoRequest(BaseModel):
|
| 79 |
-
quality: PixverseQuality = Field(...)
|
| 80 |
-
duration: PixverseDuration = Field(...)
|
| 81 |
-
img_id: int = Field(...)
|
| 82 |
-
model: Optional[str] = Field("v3.5")
|
| 83 |
-
motion_mode: Optional[PixverseMotionMode] = Field(PixverseMotionMode.normal)
|
| 84 |
-
prompt: str = Field(...)
|
| 85 |
-
negative_prompt: Optional[str] = Field(None)
|
| 86 |
-
seed: Optional[int] = Field(None)
|
| 87 |
-
style: Optional[str] = Field(None)
|
| 88 |
-
template_id: Optional[int] = Field(None)
|
| 89 |
-
water_mark: Optional[bool] = Field(None)
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
class PixverseTransitionVideoRequest(BaseModel):
|
| 93 |
-
quality: PixverseQuality = Field(...)
|
| 94 |
-
duration: PixverseDuration = Field(...)
|
| 95 |
-
first_frame_img: int = Field(...)
|
| 96 |
-
last_frame_img: int = Field(...)
|
| 97 |
-
model: Optional[str] = Field("v3.5")
|
| 98 |
-
motion_mode: Optional[PixverseMotionMode] = Field(PixverseMotionMode.normal)
|
| 99 |
-
prompt: str = Field(...)
|
| 100 |
-
# negative_prompt: Optional[str] = Field(None)
|
| 101 |
-
seed: Optional[int] = Field(None)
|
| 102 |
-
# style: Optional[str] = Field(None)
|
| 103 |
-
# template_id: Optional[int] = Field(None)
|
| 104 |
-
# water_mark: Optional[bool] = Field(None)
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
class PixverseImageUploadResponse(BaseModel):
|
| 108 |
-
ErrCode: Optional[int] = None
|
| 109 |
-
ErrMsg: Optional[str] = None
|
| 110 |
-
Resp: Optional[PixverseImgIdResponseObject] = Field(None, alias='Resp')
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
class PixverseImgIdResponseObject(BaseModel):
|
| 114 |
-
img_id: Optional[int] = None
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
class PixverseVideoResponse(BaseModel):
|
| 118 |
-
ErrCode: Optional[int] = Field(None)
|
| 119 |
-
ErrMsg: Optional[str] = Field(None)
|
| 120 |
-
Resp: Optional[PixverseVideoIdResponseObject] = Field(None)
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
class PixverseVideoIdResponseObject(BaseModel):
|
| 124 |
-
video_id: int = Field(..., description='Video_id')
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
class PixverseGenerationStatusResponse(BaseModel):
|
| 128 |
-
ErrCode: Optional[int] = Field(None)
|
| 129 |
-
ErrMsg: Optional[str] = Field(None)
|
| 130 |
-
Resp: Optional[PixverseGenerationStatusResponseObject] = Field(None)
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
class PixverseGenerationStatusResponseObject(BaseModel):
|
| 134 |
-
create_time: Optional[str] = Field(None)
|
| 135 |
-
id: Optional[int] = Field(None)
|
| 136 |
-
modify_time: Optional[str] = Field(None)
|
| 137 |
-
negative_prompt: Optional[str] = Field(None)
|
| 138 |
-
outputHeight: Optional[int] = Field(None)
|
| 139 |
-
outputWidth: Optional[int] = Field(None)
|
| 140 |
-
prompt: Optional[str] = Field(None)
|
| 141 |
-
resolution_ratio: Optional[int] = Field(None)
|
| 142 |
-
seed: Optional[int] = Field(None)
|
| 143 |
-
size: Optional[int] = Field(None)
|
| 144 |
-
status: Optional[int] = Field(None)
|
| 145 |
-
style: Optional[str] = Field(None)
|
| 146 |
-
url: Optional[str] = Field(None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/recraft_api.py
DELETED
|
@@ -1,262 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
from enum import Enum
|
| 6 |
-
from typing import Optional
|
| 7 |
-
|
| 8 |
-
from pydantic import BaseModel, Field, conint, confloat
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class RecraftColor:
|
| 12 |
-
def __init__(self, r: int, g: int, b: int):
|
| 13 |
-
self.color = [r, g, b]
|
| 14 |
-
|
| 15 |
-
def create_api_model(self):
|
| 16 |
-
return RecraftColorObject(rgb=self.color)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
class RecraftColorChain:
|
| 20 |
-
def __init__(self):
|
| 21 |
-
self.colors: list[RecraftColor] = []
|
| 22 |
-
|
| 23 |
-
def get_first(self):
|
| 24 |
-
if len(self.colors) > 0:
|
| 25 |
-
return self.colors[0]
|
| 26 |
-
return None
|
| 27 |
-
|
| 28 |
-
def add(self, color: RecraftColor):
|
| 29 |
-
self.colors.append(color)
|
| 30 |
-
|
| 31 |
-
def create_api_model(self):
|
| 32 |
-
if not self.colors:
|
| 33 |
-
return None
|
| 34 |
-
colors_api = [x.create_api_model() for x in self.colors]
|
| 35 |
-
return colors_api
|
| 36 |
-
|
| 37 |
-
def clone(self):
|
| 38 |
-
c = RecraftColorChain()
|
| 39 |
-
for color in self.colors:
|
| 40 |
-
c.add(color)
|
| 41 |
-
return c
|
| 42 |
-
|
| 43 |
-
def clone_and_merge(self, other: RecraftColorChain):
|
| 44 |
-
c = self.clone()
|
| 45 |
-
for color in other.colors:
|
| 46 |
-
c.add(color)
|
| 47 |
-
return c
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
class RecraftControls:
|
| 51 |
-
def __init__(self, colors: RecraftColorChain=None, background_color: RecraftColorChain=None,
|
| 52 |
-
artistic_level: int=None, no_text: bool=None):
|
| 53 |
-
self.colors = colors
|
| 54 |
-
self.background_color = background_color
|
| 55 |
-
self.artistic_level = artistic_level
|
| 56 |
-
self.no_text = no_text
|
| 57 |
-
|
| 58 |
-
def create_api_model(self):
|
| 59 |
-
if self.colors is None and self.background_color is None and self.artistic_level is None and self.no_text is None:
|
| 60 |
-
return None
|
| 61 |
-
colors_api = None
|
| 62 |
-
background_color_api = None
|
| 63 |
-
if self.colors:
|
| 64 |
-
colors_api = self.colors.create_api_model()
|
| 65 |
-
if self.background_color:
|
| 66 |
-
first_background = self.background_color.get_first()
|
| 67 |
-
background_color_api = first_background.create_api_model() if first_background else None
|
| 68 |
-
|
| 69 |
-
return RecraftControlsObject(colors=colors_api, background_color=background_color_api,
|
| 70 |
-
artistic_level=self.artistic_level, no_text=self.no_text)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
class RecraftStyle:
|
| 74 |
-
def __init__(self, style: str=None, substyle: str=None, style_id: str=None):
|
| 75 |
-
self.style = style
|
| 76 |
-
if substyle == "None":
|
| 77 |
-
substyle = None
|
| 78 |
-
self.substyle = substyle
|
| 79 |
-
self.style_id = style_id
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
class RecraftIO:
|
| 83 |
-
STYLEV3 = "RECRAFT_V3_STYLE"
|
| 84 |
-
COLOR = "RECRAFT_COLOR"
|
| 85 |
-
CONTROLS = "RECRAFT_CONTROLS"
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
class RecraftStyleV3(str, Enum):
|
| 89 |
-
#any = 'any' NOTE: this does not work for some reason... why?
|
| 90 |
-
realistic_image = 'realistic_image'
|
| 91 |
-
digital_illustration = 'digital_illustration'
|
| 92 |
-
vector_illustration = 'vector_illustration'
|
| 93 |
-
logo_raster = 'logo_raster'
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def get_v3_substyles(style_v3: str, include_none=True) -> list[str]:
|
| 97 |
-
substyles: list[str] = []
|
| 98 |
-
if include_none:
|
| 99 |
-
substyles.append("None")
|
| 100 |
-
return substyles + dict_recraft_substyles_v3.get(style_v3, [])
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
dict_recraft_substyles_v3 = {
|
| 104 |
-
RecraftStyleV3.realistic_image: [
|
| 105 |
-
"b_and_w",
|
| 106 |
-
"enterprise",
|
| 107 |
-
"evening_light",
|
| 108 |
-
"faded_nostalgia",
|
| 109 |
-
"forest_life",
|
| 110 |
-
"hard_flash",
|
| 111 |
-
"hdr",
|
| 112 |
-
"motion_blur",
|
| 113 |
-
"mystic_naturalism",
|
| 114 |
-
"natural_light",
|
| 115 |
-
"natural_tones",
|
| 116 |
-
"organic_calm",
|
| 117 |
-
"real_life_glow",
|
| 118 |
-
"retro_realism",
|
| 119 |
-
"retro_snapshot",
|
| 120 |
-
"studio_portrait",
|
| 121 |
-
"urban_drama",
|
| 122 |
-
"village_realism",
|
| 123 |
-
"warm_folk"
|
| 124 |
-
],
|
| 125 |
-
RecraftStyleV3.digital_illustration: [
|
| 126 |
-
"2d_art_poster",
|
| 127 |
-
"2d_art_poster_2",
|
| 128 |
-
"antiquarian",
|
| 129 |
-
"bold_fantasy",
|
| 130 |
-
"child_book",
|
| 131 |
-
"child_books",
|
| 132 |
-
"cover",
|
| 133 |
-
"crosshatch",
|
| 134 |
-
"digital_engraving",
|
| 135 |
-
"engraving_color",
|
| 136 |
-
"expressionism",
|
| 137 |
-
"freehand_details",
|
| 138 |
-
"grain",
|
| 139 |
-
"grain_20",
|
| 140 |
-
"graphic_intensity",
|
| 141 |
-
"hand_drawn",
|
| 142 |
-
"hand_drawn_outline",
|
| 143 |
-
"handmade_3d",
|
| 144 |
-
"hard_comics",
|
| 145 |
-
"infantile_sketch",
|
| 146 |
-
"long_shadow",
|
| 147 |
-
"modern_folk",
|
| 148 |
-
"multicolor",
|
| 149 |
-
"neon_calm",
|
| 150 |
-
"noir",
|
| 151 |
-
"nostalgic_pastel",
|
| 152 |
-
"outline_details",
|
| 153 |
-
"pastel_gradient",
|
| 154 |
-
"pastel_sketch",
|
| 155 |
-
"pixel_art",
|
| 156 |
-
"plastic",
|
| 157 |
-
"pop_art",
|
| 158 |
-
"pop_renaissance",
|
| 159 |
-
"seamless",
|
| 160 |
-
"street_art",
|
| 161 |
-
"tablet_sketch",
|
| 162 |
-
"urban_glow",
|
| 163 |
-
"urban_sketching",
|
| 164 |
-
"vanilla_dreams",
|
| 165 |
-
"young_adult_book",
|
| 166 |
-
"young_adult_book_2"
|
| 167 |
-
],
|
| 168 |
-
RecraftStyleV3.vector_illustration: [
|
| 169 |
-
"bold_stroke",
|
| 170 |
-
"chemistry",
|
| 171 |
-
"colored_stencil",
|
| 172 |
-
"contour_pop_art",
|
| 173 |
-
"cosmics",
|
| 174 |
-
"cutout",
|
| 175 |
-
"depressive",
|
| 176 |
-
"editorial",
|
| 177 |
-
"emotional_flat",
|
| 178 |
-
"engraving",
|
| 179 |
-
"infographical",
|
| 180 |
-
"line_art",
|
| 181 |
-
"line_circuit",
|
| 182 |
-
"linocut",
|
| 183 |
-
"marker_outline",
|
| 184 |
-
"mosaic",
|
| 185 |
-
"naivector",
|
| 186 |
-
"roundish_flat",
|
| 187 |
-
"seamless",
|
| 188 |
-
"segmented_colors",
|
| 189 |
-
"sharp_contrast",
|
| 190 |
-
"thin",
|
| 191 |
-
"vector_photo",
|
| 192 |
-
"vivid_shapes"
|
| 193 |
-
],
|
| 194 |
-
RecraftStyleV3.logo_raster: [
|
| 195 |
-
"emblem_graffiti",
|
| 196 |
-
"emblem_pop_art",
|
| 197 |
-
"emblem_punk",
|
| 198 |
-
"emblem_stamp",
|
| 199 |
-
"emblem_vintage"
|
| 200 |
-
],
|
| 201 |
-
}
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
class RecraftModel(str, Enum):
|
| 205 |
-
recraftv3 = 'recraftv3'
|
| 206 |
-
recraftv2 = 'recraftv2'
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
class RecraftImageSize(str, Enum):
|
| 210 |
-
res_1024x1024 = '1024x1024'
|
| 211 |
-
res_1365x1024 = '1365x1024'
|
| 212 |
-
res_1024x1365 = '1024x1365'
|
| 213 |
-
res_1536x1024 = '1536x1024'
|
| 214 |
-
res_1024x1536 = '1024x1536'
|
| 215 |
-
res_1820x1024 = '1820x1024'
|
| 216 |
-
res_1024x1820 = '1024x1820'
|
| 217 |
-
res_1024x2048 = '1024x2048'
|
| 218 |
-
res_2048x1024 = '2048x1024'
|
| 219 |
-
res_1434x1024 = '1434x1024'
|
| 220 |
-
res_1024x1434 = '1024x1434'
|
| 221 |
-
res_1024x1280 = '1024x1280'
|
| 222 |
-
res_1280x1024 = '1280x1024'
|
| 223 |
-
res_1024x1707 = '1024x1707'
|
| 224 |
-
res_1707x1024 = '1707x1024'
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
class RecraftColorObject(BaseModel):
|
| 228 |
-
rgb: list[int] = Field(..., description='An array of 3 integer values in range of 0...255 defining RGB Color Model')
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class RecraftControlsObject(BaseModel):
|
| 232 |
-
colors: Optional[list[RecraftColorObject]] = Field(None, description='An array of preferable colors')
|
| 233 |
-
background_color: Optional[RecraftColorObject] = Field(None, description='Use given color as a desired background color')
|
| 234 |
-
no_text: Optional[bool] = Field(None, description='Do not embed text layouts')
|
| 235 |
-
artistic_level: Optional[conint(ge=0, le=5)] = Field(None, description='Defines artistic tone of your image. At a simple level, the person looks straight at the camera in a static and clean style. Dynamic and eccentric levels introduce movement and creativity. The value should be in range [0..5].')
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
class RecraftImageGenerationRequest(BaseModel):
|
| 239 |
-
prompt: str = Field(..., description='The text prompt describing the image to generate')
|
| 240 |
-
size: Optional[RecraftImageSize] = Field(None, description='The size of the generated image (e.g., "1024x1024")')
|
| 241 |
-
n: conint(ge=1, le=6) = Field(..., description='The number of images to generate')
|
| 242 |
-
negative_prompt: Optional[str] = Field(None, description='A text description of undesired elements on an image')
|
| 243 |
-
model: Optional[RecraftModel] = Field(RecraftModel.recraftv3, description='The model to use for generation (e.g., "recraftv3")')
|
| 244 |
-
style: Optional[str] = Field(None, description='The style to apply to the generated image (e.g., "digital_illustration")')
|
| 245 |
-
substyle: Optional[str] = Field(None, description='The substyle to apply to the generated image, depending on the style input')
|
| 246 |
-
controls: Optional[RecraftControlsObject] = Field(None, description='A set of custom parameters to tweak generation process')
|
| 247 |
-
style_id: Optional[str] = Field(None, description='Use a previously uploaded style as a reference; UUID')
|
| 248 |
-
strength: Optional[confloat(ge=0.0, le=1.0)] = Field(None, description='Defines the difference with the original image, should lie in [0, 1], where 0 means almost identical, and 1 means miserable similarity')
|
| 249 |
-
random_seed: Optional[int] = Field(None, description="Seed for video generation")
|
| 250 |
-
# text_layout
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
class RecraftReturnedObject(BaseModel):
|
| 254 |
-
image_id: str = Field(..., description='Unique identifier for the generated image')
|
| 255 |
-
url: str = Field(..., description='URL to access the generated image')
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
class RecraftImageGenerationResponse(BaseModel):
|
| 259 |
-
created: int = Field(..., description='Unix timestamp when the generation was created')
|
| 260 |
-
credits: int = Field(..., description='Number of credits used for the generation')
|
| 261 |
-
data: Optional[list[RecraftReturnedObject]] = Field(None, description='Array of generated image information')
|
| 262 |
-
image: Optional[RecraftReturnedObject] = Field(None, description='Single generated image')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/request_logger.py
DELETED
|
@@ -1,125 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import datetime
|
| 3 |
-
import json
|
| 4 |
-
import logging
|
| 5 |
-
import folder_paths
|
| 6 |
-
|
| 7 |
-
# Get the logger instance
|
| 8 |
-
logger = logging.getLogger(__name__)
|
| 9 |
-
|
| 10 |
-
def get_log_directory():
|
| 11 |
-
"""
|
| 12 |
-
Ensures the API log directory exists within ComfyUI's temp directory
|
| 13 |
-
and returns its path.
|
| 14 |
-
"""
|
| 15 |
-
base_temp_dir = folder_paths.get_temp_directory()
|
| 16 |
-
log_dir = os.path.join(base_temp_dir, "api_logs")
|
| 17 |
-
try:
|
| 18 |
-
os.makedirs(log_dir, exist_ok=True)
|
| 19 |
-
except Exception as e:
|
| 20 |
-
logger.error(f"Error creating API log directory {log_dir}: {e}")
|
| 21 |
-
# Fallback to base temp directory if sub-directory creation fails
|
| 22 |
-
return base_temp_dir
|
| 23 |
-
return log_dir
|
| 24 |
-
|
| 25 |
-
def _format_data_for_logging(data):
|
| 26 |
-
"""Helper to format data (dict, str, bytes) for logging."""
|
| 27 |
-
if isinstance(data, bytes):
|
| 28 |
-
try:
|
| 29 |
-
return data.decode('utf-8') # Try to decode as text
|
| 30 |
-
except UnicodeDecodeError:
|
| 31 |
-
return f"[Binary data of length {len(data)} bytes]"
|
| 32 |
-
elif isinstance(data, (dict, list)):
|
| 33 |
-
try:
|
| 34 |
-
return json.dumps(data, indent=2, ensure_ascii=False)
|
| 35 |
-
except TypeError:
|
| 36 |
-
return str(data) # Fallback for non-serializable objects
|
| 37 |
-
return str(data)
|
| 38 |
-
|
| 39 |
-
def log_request_response(
|
| 40 |
-
operation_id: str,
|
| 41 |
-
request_method: str,
|
| 42 |
-
request_url: str,
|
| 43 |
-
request_headers: dict | None = None,
|
| 44 |
-
request_params: dict | None = None,
|
| 45 |
-
request_data: any = None,
|
| 46 |
-
response_status_code: int | None = None,
|
| 47 |
-
response_headers: dict | None = None,
|
| 48 |
-
response_content: any = None,
|
| 49 |
-
error_message: str | None = None
|
| 50 |
-
):
|
| 51 |
-
"""
|
| 52 |
-
Logs API request and response details to a file in the temp/api_logs directory.
|
| 53 |
-
"""
|
| 54 |
-
log_dir = get_log_directory()
|
| 55 |
-
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 56 |
-
filename = f"{timestamp}_{operation_id.replace('/', '_').replace(':', '_')}.log"
|
| 57 |
-
filepath = os.path.join(log_dir, filename)
|
| 58 |
-
|
| 59 |
-
log_content = []
|
| 60 |
-
|
| 61 |
-
log_content.append(f"Timestamp: {datetime.datetime.now().isoformat()}")
|
| 62 |
-
log_content.append(f"Operation ID: {operation_id}")
|
| 63 |
-
log_content.append("-" * 30 + " REQUEST " + "-" * 30)
|
| 64 |
-
log_content.append(f"Method: {request_method}")
|
| 65 |
-
log_content.append(f"URL: {request_url}")
|
| 66 |
-
if request_headers:
|
| 67 |
-
log_content.append(f"Headers:\n{_format_data_for_logging(request_headers)}")
|
| 68 |
-
if request_params:
|
| 69 |
-
log_content.append(f"Params:\n{_format_data_for_logging(request_params)}")
|
| 70 |
-
if request_data:
|
| 71 |
-
log_content.append(f"Data/Body:\n{_format_data_for_logging(request_data)}")
|
| 72 |
-
|
| 73 |
-
log_content.append("\n" + "-" * 30 + " RESPONSE " + "-" * 30)
|
| 74 |
-
if response_status_code is not None:
|
| 75 |
-
log_content.append(f"Status Code: {response_status_code}")
|
| 76 |
-
if response_headers:
|
| 77 |
-
log_content.append(f"Headers:\n{_format_data_for_logging(response_headers)}")
|
| 78 |
-
if response_content:
|
| 79 |
-
log_content.append(f"Content:\n{_format_data_for_logging(response_content)}")
|
| 80 |
-
if error_message:
|
| 81 |
-
log_content.append(f"Error:\n{error_message}")
|
| 82 |
-
|
| 83 |
-
try:
|
| 84 |
-
with open(filepath, "w", encoding="utf-8") as f:
|
| 85 |
-
f.write("\n".join(log_content))
|
| 86 |
-
logger.debug(f"API log saved to: {filepath}")
|
| 87 |
-
except Exception as e:
|
| 88 |
-
logger.error(f"Error writing API log to {filepath}: {e}")
|
| 89 |
-
|
| 90 |
-
if __name__ == '__main__':
|
| 91 |
-
# Example usage (for testing the logger directly)
|
| 92 |
-
logger.setLevel(logging.DEBUG)
|
| 93 |
-
# Mock folder_paths for direct execution if not running within ComfyUI full context
|
| 94 |
-
if not hasattr(folder_paths, 'get_temp_directory'):
|
| 95 |
-
class MockFolderPaths:
|
| 96 |
-
def get_temp_directory(self):
|
| 97 |
-
# Create a local temp dir for testing if needed
|
| 98 |
-
p = os.path.join(os.path.dirname(__file__), 'temp_test_logs')
|
| 99 |
-
os.makedirs(p, exist_ok=True)
|
| 100 |
-
return p
|
| 101 |
-
folder_paths = MockFolderPaths()
|
| 102 |
-
|
| 103 |
-
log_request_response(
|
| 104 |
-
operation_id="test_operation_get",
|
| 105 |
-
request_method="GET",
|
| 106 |
-
request_url="https://api.example.com/test",
|
| 107 |
-
request_headers={"Authorization": "Bearer testtoken"},
|
| 108 |
-
request_params={"param1": "value1"},
|
| 109 |
-
response_status_code=200,
|
| 110 |
-
response_content={"message": "Success!"}
|
| 111 |
-
)
|
| 112 |
-
log_request_response(
|
| 113 |
-
operation_id="test_operation_post_error",
|
| 114 |
-
request_method="POST",
|
| 115 |
-
request_url="https://api.example.com/submit",
|
| 116 |
-
request_data={"key": "value", "nested": {"num": 123}},
|
| 117 |
-
error_message="Connection timed out"
|
| 118 |
-
)
|
| 119 |
-
log_request_response(
|
| 120 |
-
operation_id="test_binary_response",
|
| 121 |
-
request_method="GET",
|
| 122 |
-
request_url="https://api.example.com/image.png",
|
| 123 |
-
response_status_code=200,
|
| 124 |
-
response_content=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR...' # Sample binary data
|
| 125 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/rodin_api.py
DELETED
|
@@ -1,57 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from enum import Enum
|
| 4 |
-
from typing import Optional, List
|
| 5 |
-
from pydantic import BaseModel, Field
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class Rodin3DGenerateRequest(BaseModel):
|
| 9 |
-
seed: int = Field(..., description="seed_")
|
| 10 |
-
tier: str = Field(..., description="Tier of generation.")
|
| 11 |
-
material: str = Field(..., description="The material type.")
|
| 12 |
-
quality: str = Field(..., description="The generation quality of the mesh.")
|
| 13 |
-
mesh_mode: str = Field(..., description="It controls the type of faces of generated models.")
|
| 14 |
-
|
| 15 |
-
class GenerateJobsData(BaseModel):
|
| 16 |
-
uuids: List[str] = Field(..., description="str LIST")
|
| 17 |
-
subscription_key: str = Field(..., description="subscription key")
|
| 18 |
-
|
| 19 |
-
class Rodin3DGenerateResponse(BaseModel):
|
| 20 |
-
message: Optional[str] = Field(None, description="Return message.")
|
| 21 |
-
prompt: Optional[str] = Field(None, description="Generated Prompt from image.")
|
| 22 |
-
submit_time: Optional[str] = Field(None, description="Submit Time")
|
| 23 |
-
uuid: Optional[str] = Field(None, description="Task str")
|
| 24 |
-
jobs: Optional[GenerateJobsData] = Field(None, description="Details of jobs")
|
| 25 |
-
|
| 26 |
-
class JobStatus(str, Enum):
|
| 27 |
-
"""
|
| 28 |
-
Status for jobs
|
| 29 |
-
"""
|
| 30 |
-
Done = "Done"
|
| 31 |
-
Failed = "Failed"
|
| 32 |
-
Generating = "Generating"
|
| 33 |
-
Waiting = "Waiting"
|
| 34 |
-
|
| 35 |
-
class Rodin3DCheckStatusRequest(BaseModel):
|
| 36 |
-
subscription_key: str = Field(..., description="subscription from generate endpoint")
|
| 37 |
-
|
| 38 |
-
class JobItem(BaseModel):
|
| 39 |
-
uuid: str = Field(..., description="uuid")
|
| 40 |
-
status: JobStatus = Field(...,description="Status Currently")
|
| 41 |
-
|
| 42 |
-
class Rodin3DCheckStatusResponse(BaseModel):
|
| 43 |
-
jobs: List[JobItem] = Field(..., description="Job status List")
|
| 44 |
-
|
| 45 |
-
class Rodin3DDownloadRequest(BaseModel):
|
| 46 |
-
task_uuid: str = Field(..., description="Task str")
|
| 47 |
-
|
| 48 |
-
class RodinResourceItem(BaseModel):
|
| 49 |
-
url: str = Field(..., description="Download Url")
|
| 50 |
-
name: str = Field(..., description="File name with ext")
|
| 51 |
-
|
| 52 |
-
class Rodin3DDownloadResponse(BaseModel):
|
| 53 |
-
list: List[RodinResourceItem] = Field(..., description="Source List")
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/stability_api.py
DELETED
|
@@ -1,127 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from enum import Enum
|
| 4 |
-
from typing import Optional
|
| 5 |
-
|
| 6 |
-
from pydantic import BaseModel, Field, confloat
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
class StabilityFormat(str, Enum):
|
| 10 |
-
png = 'png'
|
| 11 |
-
jpeg = 'jpeg'
|
| 12 |
-
webp = 'webp'
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class StabilityAspectRatio(str, Enum):
|
| 16 |
-
ratio_1_1 = "1:1"
|
| 17 |
-
ratio_16_9 = "16:9"
|
| 18 |
-
ratio_9_16 = "9:16"
|
| 19 |
-
ratio_3_2 = "3:2"
|
| 20 |
-
ratio_2_3 = "2:3"
|
| 21 |
-
ratio_5_4 = "5:4"
|
| 22 |
-
ratio_4_5 = "4:5"
|
| 23 |
-
ratio_21_9 = "21:9"
|
| 24 |
-
ratio_9_21 = "9:21"
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def get_stability_style_presets(include_none=True):
|
| 28 |
-
presets = []
|
| 29 |
-
if include_none:
|
| 30 |
-
presets.append("None")
|
| 31 |
-
return presets + [x.value for x in StabilityStylePreset]
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
class StabilityStylePreset(str, Enum):
|
| 35 |
-
_3d_model = "3d-model"
|
| 36 |
-
analog_film = "analog-film"
|
| 37 |
-
anime = "anime"
|
| 38 |
-
cinematic = "cinematic"
|
| 39 |
-
comic_book = "comic-book"
|
| 40 |
-
digital_art = "digital-art"
|
| 41 |
-
enhance = "enhance"
|
| 42 |
-
fantasy_art = "fantasy-art"
|
| 43 |
-
isometric = "isometric"
|
| 44 |
-
line_art = "line-art"
|
| 45 |
-
low_poly = "low-poly"
|
| 46 |
-
modeling_compound = "modeling-compound"
|
| 47 |
-
neon_punk = "neon-punk"
|
| 48 |
-
origami = "origami"
|
| 49 |
-
photographic = "photographic"
|
| 50 |
-
pixel_art = "pixel-art"
|
| 51 |
-
tile_texture = "tile-texture"
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
class Stability_SD3_5_Model(str, Enum):
|
| 55 |
-
sd3_5_large = "sd3.5-large"
|
| 56 |
-
# sd3_5_large_turbo = "sd3.5-large-turbo"
|
| 57 |
-
sd3_5_medium = "sd3.5-medium"
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
class Stability_SD3_5_GenerationMode(str, Enum):
|
| 61 |
-
text_to_image = "text-to-image"
|
| 62 |
-
image_to_image = "image-to-image"
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
class StabilityStable3_5Request(BaseModel):
|
| 66 |
-
model: str = Field(...)
|
| 67 |
-
mode: str = Field(...)
|
| 68 |
-
prompt: str = Field(...)
|
| 69 |
-
negative_prompt: Optional[str] = Field(None)
|
| 70 |
-
aspect_ratio: Optional[str] = Field(None)
|
| 71 |
-
seed: Optional[int] = Field(None)
|
| 72 |
-
output_format: Optional[str] = Field(StabilityFormat.png.value)
|
| 73 |
-
image: Optional[str] = Field(None)
|
| 74 |
-
style_preset: Optional[str] = Field(None)
|
| 75 |
-
cfg_scale: float = Field(...)
|
| 76 |
-
strength: Optional[confloat(ge=0.0, le=1.0)] = Field(None)
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
class StabilityUpscaleConservativeRequest(BaseModel):
|
| 80 |
-
prompt: str = Field(...)
|
| 81 |
-
negative_prompt: Optional[str] = Field(None)
|
| 82 |
-
seed: Optional[int] = Field(None)
|
| 83 |
-
output_format: Optional[str] = Field(StabilityFormat.png.value)
|
| 84 |
-
image: Optional[str] = Field(None)
|
| 85 |
-
creativity: Optional[confloat(ge=0.2, le=0.5)] = Field(None)
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
class StabilityUpscaleCreativeRequest(BaseModel):
|
| 89 |
-
prompt: str = Field(...)
|
| 90 |
-
negative_prompt: Optional[str] = Field(None)
|
| 91 |
-
seed: Optional[int] = Field(None)
|
| 92 |
-
output_format: Optional[str] = Field(StabilityFormat.png.value)
|
| 93 |
-
image: Optional[str] = Field(None)
|
| 94 |
-
creativity: Optional[confloat(ge=0.1, le=0.5)] = Field(None)
|
| 95 |
-
style_preset: Optional[str] = Field(None)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
class StabilityStableUltraRequest(BaseModel):
|
| 99 |
-
prompt: str = Field(...)
|
| 100 |
-
negative_prompt: Optional[str] = Field(None)
|
| 101 |
-
aspect_ratio: Optional[str] = Field(None)
|
| 102 |
-
seed: Optional[int] = Field(None)
|
| 103 |
-
output_format: Optional[str] = Field(StabilityFormat.png.value)
|
| 104 |
-
image: Optional[str] = Field(None)
|
| 105 |
-
style_preset: Optional[str] = Field(None)
|
| 106 |
-
strength: Optional[confloat(ge=0.0, le=1.0)] = Field(None)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
class StabilityStableUltraResponse(BaseModel):
|
| 110 |
-
image: Optional[str] = Field(None)
|
| 111 |
-
finish_reason: Optional[str] = Field(None)
|
| 112 |
-
seed: Optional[int] = Field(None)
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
class StabilityResultsGetResponse(BaseModel):
|
| 116 |
-
image: Optional[str] = Field(None)
|
| 117 |
-
finish_reason: Optional[str] = Field(None)
|
| 118 |
-
seed: Optional[int] = Field(None)
|
| 119 |
-
id: Optional[str] = Field(None)
|
| 120 |
-
name: Optional[str] = Field(None)
|
| 121 |
-
errors: Optional[list[str]] = Field(None)
|
| 122 |
-
status: Optional[str] = Field(None)
|
| 123 |
-
result: Optional[str] = Field(None)
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
class StabilityAsyncResponse(BaseModel):
|
| 127 |
-
id: Optional[str] = Field(None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/apis/tripo_api.py
DELETED
|
@@ -1,275 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
from comfy_api_nodes.apis import (
|
| 3 |
-
TripoModelVersion,
|
| 4 |
-
TripoTextureQuality,
|
| 5 |
-
)
|
| 6 |
-
from enum import Enum
|
| 7 |
-
from typing import Optional, List, Dict, Any, Union
|
| 8 |
-
|
| 9 |
-
from pydantic import BaseModel, Field, RootModel
|
| 10 |
-
|
| 11 |
-
class TripoStyle(str, Enum):
|
| 12 |
-
PERSON_TO_CARTOON = "person:person2cartoon"
|
| 13 |
-
ANIMAL_VENOM = "animal:venom"
|
| 14 |
-
OBJECT_CLAY = "object:clay"
|
| 15 |
-
OBJECT_STEAMPUNK = "object:steampunk"
|
| 16 |
-
OBJECT_CHRISTMAS = "object:christmas"
|
| 17 |
-
OBJECT_BARBIE = "object:barbie"
|
| 18 |
-
GOLD = "gold"
|
| 19 |
-
ANCIENT_BRONZE = "ancient_bronze"
|
| 20 |
-
NONE = "None"
|
| 21 |
-
|
| 22 |
-
class TripoTaskType(str, Enum):
|
| 23 |
-
TEXT_TO_MODEL = "text_to_model"
|
| 24 |
-
IMAGE_TO_MODEL = "image_to_model"
|
| 25 |
-
MULTIVIEW_TO_MODEL = "multiview_to_model"
|
| 26 |
-
TEXTURE_MODEL = "texture_model"
|
| 27 |
-
REFINE_MODEL = "refine_model"
|
| 28 |
-
ANIMATE_PRERIGCHECK = "animate_prerigcheck"
|
| 29 |
-
ANIMATE_RIG = "animate_rig"
|
| 30 |
-
ANIMATE_RETARGET = "animate_retarget"
|
| 31 |
-
STYLIZE_MODEL = "stylize_model"
|
| 32 |
-
CONVERT_MODEL = "convert_model"
|
| 33 |
-
|
| 34 |
-
class TripoTextureAlignment(str, Enum):
|
| 35 |
-
ORIGINAL_IMAGE = "original_image"
|
| 36 |
-
GEOMETRY = "geometry"
|
| 37 |
-
|
| 38 |
-
class TripoOrientation(str, Enum):
|
| 39 |
-
ALIGN_IMAGE = "align_image"
|
| 40 |
-
DEFAULT = "default"
|
| 41 |
-
|
| 42 |
-
class TripoOutFormat(str, Enum):
|
| 43 |
-
GLB = "glb"
|
| 44 |
-
FBX = "fbx"
|
| 45 |
-
|
| 46 |
-
class TripoTopology(str, Enum):
|
| 47 |
-
BIP = "bip"
|
| 48 |
-
QUAD = "quad"
|
| 49 |
-
|
| 50 |
-
class TripoSpec(str, Enum):
|
| 51 |
-
MIXAMO = "mixamo"
|
| 52 |
-
TRIPO = "tripo"
|
| 53 |
-
|
| 54 |
-
class TripoAnimation(str, Enum):
|
| 55 |
-
IDLE = "preset:idle"
|
| 56 |
-
WALK = "preset:walk"
|
| 57 |
-
CLIMB = "preset:climb"
|
| 58 |
-
JUMP = "preset:jump"
|
| 59 |
-
RUN = "preset:run"
|
| 60 |
-
SLASH = "preset:slash"
|
| 61 |
-
SHOOT = "preset:shoot"
|
| 62 |
-
HURT = "preset:hurt"
|
| 63 |
-
FALL = "preset:fall"
|
| 64 |
-
TURN = "preset:turn"
|
| 65 |
-
|
| 66 |
-
class TripoStylizeStyle(str, Enum):
|
| 67 |
-
LEGO = "lego"
|
| 68 |
-
VOXEL = "voxel"
|
| 69 |
-
VORONOI = "voronoi"
|
| 70 |
-
MINECRAFT = "minecraft"
|
| 71 |
-
|
| 72 |
-
class TripoConvertFormat(str, Enum):
|
| 73 |
-
GLTF = "GLTF"
|
| 74 |
-
USDZ = "USDZ"
|
| 75 |
-
FBX = "FBX"
|
| 76 |
-
OBJ = "OBJ"
|
| 77 |
-
STL = "STL"
|
| 78 |
-
_3MF = "3MF"
|
| 79 |
-
|
| 80 |
-
class TripoTextureFormat(str, Enum):
|
| 81 |
-
BMP = "BMP"
|
| 82 |
-
DPX = "DPX"
|
| 83 |
-
HDR = "HDR"
|
| 84 |
-
JPEG = "JPEG"
|
| 85 |
-
OPEN_EXR = "OPEN_EXR"
|
| 86 |
-
PNG = "PNG"
|
| 87 |
-
TARGA = "TARGA"
|
| 88 |
-
TIFF = "TIFF"
|
| 89 |
-
WEBP = "WEBP"
|
| 90 |
-
|
| 91 |
-
class TripoTaskStatus(str, Enum):
|
| 92 |
-
QUEUED = "queued"
|
| 93 |
-
RUNNING = "running"
|
| 94 |
-
SUCCESS = "success"
|
| 95 |
-
FAILED = "failed"
|
| 96 |
-
CANCELLED = "cancelled"
|
| 97 |
-
UNKNOWN = "unknown"
|
| 98 |
-
BANNED = "banned"
|
| 99 |
-
EXPIRED = "expired"
|
| 100 |
-
|
| 101 |
-
class TripoFileTokenReference(BaseModel):
|
| 102 |
-
type: Optional[str] = Field(None, description='The type of the reference')
|
| 103 |
-
file_token: str
|
| 104 |
-
|
| 105 |
-
class TripoUrlReference(BaseModel):
|
| 106 |
-
type: Optional[str] = Field(None, description='The type of the reference')
|
| 107 |
-
url: str
|
| 108 |
-
|
| 109 |
-
class TripoObjectStorage(BaseModel):
|
| 110 |
-
bucket: str
|
| 111 |
-
key: str
|
| 112 |
-
|
| 113 |
-
class TripoObjectReference(BaseModel):
|
| 114 |
-
type: str
|
| 115 |
-
object: TripoObjectStorage
|
| 116 |
-
|
| 117 |
-
class TripoFileEmptyReference(BaseModel):
|
| 118 |
-
pass
|
| 119 |
-
|
| 120 |
-
class TripoFileReference(RootModel):
|
| 121 |
-
root: Union[TripoFileTokenReference, TripoUrlReference, TripoObjectReference, TripoFileEmptyReference]
|
| 122 |
-
|
| 123 |
-
class TripoGetStsTokenRequest(BaseModel):
|
| 124 |
-
format: str = Field(..., description='The format of the image')
|
| 125 |
-
|
| 126 |
-
class TripoTextToModelRequest(BaseModel):
|
| 127 |
-
type: TripoTaskType = Field(TripoTaskType.TEXT_TO_MODEL, description='Type of task')
|
| 128 |
-
prompt: str = Field(..., description='The text prompt describing the model to generate', max_length=1024)
|
| 129 |
-
negative_prompt: Optional[str] = Field(None, description='The negative text prompt', max_length=1024)
|
| 130 |
-
model_version: Optional[TripoModelVersion] = TripoModelVersion.V2_5
|
| 131 |
-
face_limit: Optional[int] = Field(None, description='The number of faces to limit the generation to')
|
| 132 |
-
texture: Optional[bool] = Field(True, description='Whether to apply texture to the generated model')
|
| 133 |
-
pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the generated model')
|
| 134 |
-
image_seed: Optional[int] = Field(None, description='The seed for the text')
|
| 135 |
-
model_seed: Optional[int] = Field(None, description='The seed for the model')
|
| 136 |
-
texture_seed: Optional[int] = Field(None, description='The seed for the texture')
|
| 137 |
-
texture_quality: Optional[TripoTextureQuality] = TripoTextureQuality.standard
|
| 138 |
-
style: Optional[TripoStyle] = None
|
| 139 |
-
auto_size: Optional[bool] = Field(False, description='Whether to auto-size the model')
|
| 140 |
-
quad: Optional[bool] = Field(False, description='Whether to apply quad to the generated model')
|
| 141 |
-
|
| 142 |
-
class TripoImageToModelRequest(BaseModel):
|
| 143 |
-
type: TripoTaskType = Field(TripoTaskType.IMAGE_TO_MODEL, description='Type of task')
|
| 144 |
-
file: TripoFileReference = Field(..., description='The file reference to convert to a model')
|
| 145 |
-
model_version: Optional[TripoModelVersion] = Field(None, description='The model version to use for generation')
|
| 146 |
-
face_limit: Optional[int] = Field(None, description='The number of faces to limit the generation to')
|
| 147 |
-
texture: Optional[bool] = Field(True, description='Whether to apply texture to the generated model')
|
| 148 |
-
pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the generated model')
|
| 149 |
-
model_seed: Optional[int] = Field(None, description='The seed for the model')
|
| 150 |
-
texture_seed: Optional[int] = Field(None, description='The seed for the texture')
|
| 151 |
-
texture_quality: Optional[TripoTextureQuality] = TripoTextureQuality.standard
|
| 152 |
-
texture_alignment: Optional[TripoTextureAlignment] = Field(TripoTextureAlignment.ORIGINAL_IMAGE, description='The texture alignment method')
|
| 153 |
-
style: Optional[TripoStyle] = Field(None, description='The style to apply to the generated model')
|
| 154 |
-
auto_size: Optional[bool] = Field(False, description='Whether to auto-size the model')
|
| 155 |
-
orientation: Optional[TripoOrientation] = TripoOrientation.DEFAULT
|
| 156 |
-
quad: Optional[bool] = Field(False, description='Whether to apply quad to the generated model')
|
| 157 |
-
|
| 158 |
-
class TripoMultiviewToModelRequest(BaseModel):
|
| 159 |
-
type: TripoTaskType = TripoTaskType.MULTIVIEW_TO_MODEL
|
| 160 |
-
files: List[TripoFileReference] = Field(..., description='The file references to convert to a model')
|
| 161 |
-
model_version: Optional[TripoModelVersion] = Field(None, description='The model version to use for generation')
|
| 162 |
-
orthographic_projection: Optional[bool] = Field(False, description='Whether to use orthographic projection')
|
| 163 |
-
face_limit: Optional[int] = Field(None, description='The number of faces to limit the generation to')
|
| 164 |
-
texture: Optional[bool] = Field(True, description='Whether to apply texture to the generated model')
|
| 165 |
-
pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the generated model')
|
| 166 |
-
model_seed: Optional[int] = Field(None, description='The seed for the model')
|
| 167 |
-
texture_seed: Optional[int] = Field(None, description='The seed for the texture')
|
| 168 |
-
texture_quality: Optional[TripoTextureQuality] = TripoTextureQuality.standard
|
| 169 |
-
texture_alignment: Optional[TripoTextureAlignment] = TripoTextureAlignment.ORIGINAL_IMAGE
|
| 170 |
-
auto_size: Optional[bool] = Field(False, description='Whether to auto-size the model')
|
| 171 |
-
orientation: Optional[TripoOrientation] = Field(TripoOrientation.DEFAULT, description='The orientation for the model')
|
| 172 |
-
quad: Optional[bool] = Field(False, description='Whether to apply quad to the generated model')
|
| 173 |
-
|
| 174 |
-
class TripoTextureModelRequest(BaseModel):
|
| 175 |
-
type: TripoTaskType = Field(TripoTaskType.TEXTURE_MODEL, description='Type of task')
|
| 176 |
-
original_model_task_id: str = Field(..., description='The task ID of the original model')
|
| 177 |
-
texture: Optional[bool] = Field(True, description='Whether to apply texture to the model')
|
| 178 |
-
pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the model')
|
| 179 |
-
model_seed: Optional[int] = Field(None, description='The seed for the model')
|
| 180 |
-
texture_seed: Optional[int] = Field(None, description='The seed for the texture')
|
| 181 |
-
texture_quality: Optional[TripoTextureQuality] = Field(None, description='The quality of the texture')
|
| 182 |
-
texture_alignment: Optional[TripoTextureAlignment] = Field(TripoTextureAlignment.ORIGINAL_IMAGE, description='The texture alignment method')
|
| 183 |
-
|
| 184 |
-
class TripoRefineModelRequest(BaseModel):
|
| 185 |
-
type: TripoTaskType = Field(TripoTaskType.REFINE_MODEL, description='Type of task')
|
| 186 |
-
draft_model_task_id: str = Field(..., description='The task ID of the draft model')
|
| 187 |
-
|
| 188 |
-
class TripoAnimatePrerigcheckRequest(BaseModel):
|
| 189 |
-
type: TripoTaskType = Field(TripoTaskType.ANIMATE_PRERIGCHECK, description='Type of task')
|
| 190 |
-
original_model_task_id: str = Field(..., description='The task ID of the original model')
|
| 191 |
-
|
| 192 |
-
class TripoAnimateRigRequest(BaseModel):
|
| 193 |
-
type: TripoTaskType = Field(TripoTaskType.ANIMATE_RIG, description='Type of task')
|
| 194 |
-
original_model_task_id: str = Field(..., description='The task ID of the original model')
|
| 195 |
-
out_format: Optional[TripoOutFormat] = Field(TripoOutFormat.GLB, description='The output format')
|
| 196 |
-
spec: Optional[TripoSpec] = Field(TripoSpec.TRIPO, description='The specification for rigging')
|
| 197 |
-
|
| 198 |
-
class TripoAnimateRetargetRequest(BaseModel):
|
| 199 |
-
type: TripoTaskType = Field(TripoTaskType.ANIMATE_RETARGET, description='Type of task')
|
| 200 |
-
original_model_task_id: str = Field(..., description='The task ID of the original model')
|
| 201 |
-
animation: TripoAnimation = Field(..., description='The animation to apply')
|
| 202 |
-
out_format: Optional[TripoOutFormat] = Field(TripoOutFormat.GLB, description='The output format')
|
| 203 |
-
bake_animation: Optional[bool] = Field(True, description='Whether to bake the animation')
|
| 204 |
-
|
| 205 |
-
class TripoStylizeModelRequest(BaseModel):
|
| 206 |
-
type: TripoTaskType = Field(TripoTaskType.STYLIZE_MODEL, description='Type of task')
|
| 207 |
-
style: TripoStylizeStyle = Field(..., description='The style to apply to the model')
|
| 208 |
-
original_model_task_id: str = Field(..., description='The task ID of the original model')
|
| 209 |
-
block_size: Optional[int] = Field(80, description='The block size for stylization')
|
| 210 |
-
|
| 211 |
-
class TripoConvertModelRequest(BaseModel):
|
| 212 |
-
type: TripoTaskType = Field(TripoTaskType.CONVERT_MODEL, description='Type of task')
|
| 213 |
-
format: TripoConvertFormat = Field(..., description='The format to convert to')
|
| 214 |
-
original_model_task_id: str = Field(..., description='The task ID of the original model')
|
| 215 |
-
quad: Optional[bool] = Field(False, description='Whether to apply quad to the model')
|
| 216 |
-
force_symmetry: Optional[bool] = Field(False, description='Whether to force symmetry')
|
| 217 |
-
face_limit: Optional[int] = Field(10000, description='The number of faces to limit the conversion to')
|
| 218 |
-
flatten_bottom: Optional[bool] = Field(False, description='Whether to flatten the bottom of the model')
|
| 219 |
-
flatten_bottom_threshold: Optional[float] = Field(0.01, description='The threshold for flattening the bottom')
|
| 220 |
-
texture_size: Optional[int] = Field(4096, description='The size of the texture')
|
| 221 |
-
texture_format: Optional[TripoTextureFormat] = Field(TripoTextureFormat.JPEG, description='The format of the texture')
|
| 222 |
-
pivot_to_center_bottom: Optional[bool] = Field(False, description='Whether to pivot to the center bottom')
|
| 223 |
-
|
| 224 |
-
class TripoTaskRequest(RootModel):
|
| 225 |
-
root: Union[
|
| 226 |
-
TripoTextToModelRequest,
|
| 227 |
-
TripoImageToModelRequest,
|
| 228 |
-
TripoMultiviewToModelRequest,
|
| 229 |
-
TripoTextureModelRequest,
|
| 230 |
-
TripoRefineModelRequest,
|
| 231 |
-
TripoAnimatePrerigcheckRequest,
|
| 232 |
-
TripoAnimateRigRequest,
|
| 233 |
-
TripoAnimateRetargetRequest,
|
| 234 |
-
TripoStylizeModelRequest,
|
| 235 |
-
TripoConvertModelRequest
|
| 236 |
-
]
|
| 237 |
-
|
| 238 |
-
class TripoTaskOutput(BaseModel):
|
| 239 |
-
model: Optional[str] = Field(None, description='URL to the model')
|
| 240 |
-
base_model: Optional[str] = Field(None, description='URL to the base model')
|
| 241 |
-
pbr_model: Optional[str] = Field(None, description='URL to the PBR model')
|
| 242 |
-
rendered_image: Optional[str] = Field(None, description='URL to the rendered image')
|
| 243 |
-
riggable: Optional[bool] = Field(None, description='Whether the model is riggable')
|
| 244 |
-
|
| 245 |
-
class TripoTask(BaseModel):
|
| 246 |
-
task_id: str = Field(..., description='The task ID')
|
| 247 |
-
type: Optional[str] = Field(None, description='The type of task')
|
| 248 |
-
status: Optional[TripoTaskStatus] = Field(None, description='The status of the task')
|
| 249 |
-
input: Optional[Dict[str, Any]] = Field(None, description='The input parameters for the task')
|
| 250 |
-
output: Optional[TripoTaskOutput] = Field(None, description='The output of the task')
|
| 251 |
-
progress: Optional[int] = Field(None, description='The progress of the task', ge=0, le=100)
|
| 252 |
-
create_time: Optional[int] = Field(None, description='The creation time of the task')
|
| 253 |
-
running_left_time: Optional[int] = Field(None, description='The estimated time left for the task')
|
| 254 |
-
queue_position: Optional[int] = Field(None, description='The position in the queue')
|
| 255 |
-
|
| 256 |
-
class TripoTaskResponse(BaseModel):
|
| 257 |
-
code: int = Field(0, description='The response code')
|
| 258 |
-
data: TripoTask = Field(..., description='The task data')
|
| 259 |
-
|
| 260 |
-
class TripoGeneralResponse(BaseModel):
|
| 261 |
-
code: int = Field(0, description='The response code')
|
| 262 |
-
data: Dict[str, str] = Field(..., description='The task ID data')
|
| 263 |
-
|
| 264 |
-
class TripoBalanceData(BaseModel):
|
| 265 |
-
balance: float = Field(..., description='The account balance')
|
| 266 |
-
frozen: float = Field(..., description='The frozen balance')
|
| 267 |
-
|
| 268 |
-
class TripoBalanceResponse(BaseModel):
|
| 269 |
-
code: int = Field(0, description='The response code')
|
| 270 |
-
data: TripoBalanceData = Field(..., description='The balance data')
|
| 271 |
-
|
| 272 |
-
class TripoErrorResponse(BaseModel):
|
| 273 |
-
code: int = Field(..., description='The error code')
|
| 274 |
-
message: str = Field(..., description='The error message')
|
| 275 |
-
suggestion: str = Field(..., description='The suggestion for fixing the error')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/canary.py
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
import av
|
| 2 |
-
|
| 3 |
-
ver = av.__version__.split(".")
|
| 4 |
-
if int(ver[0]) < 14:
|
| 5 |
-
raise Exception("INSTALL NEW VERSION OF PYAV TO USE API NODES.")
|
| 6 |
-
|
| 7 |
-
if int(ver[0]) == 14 and int(ver[1]) < 2:
|
| 8 |
-
raise Exception("INSTALL NEW VERSION OF PYAV TO USE API NODES.")
|
| 9 |
-
|
| 10 |
-
NODE_CLASS_MAPPINGS = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/mapper_utils.py
DELETED
|
@@ -1,116 +0,0 @@
|
|
| 1 |
-
from enum import Enum
|
| 2 |
-
|
| 3 |
-
from pydantic.fields import FieldInfo
|
| 4 |
-
from pydantic import BaseModel
|
| 5 |
-
from pydantic_core import PydanticUndefined
|
| 6 |
-
|
| 7 |
-
from comfy.comfy_types.node_typing import IO, InputTypeOptions
|
| 8 |
-
|
| 9 |
-
NodeInput = tuple[IO, InputTypeOptions]
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def _create_base_config(field_info: FieldInfo) -> InputTypeOptions:
|
| 13 |
-
config = {}
|
| 14 |
-
if hasattr(field_info, "default") and field_info.default is not PydanticUndefined:
|
| 15 |
-
config["default"] = field_info.default
|
| 16 |
-
if hasattr(field_info, "description") and field_info.description is not None:
|
| 17 |
-
config["tooltip"] = field_info.description
|
| 18 |
-
return config
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def _get_number_constraints_config(field_info: FieldInfo) -> dict:
|
| 22 |
-
config = {}
|
| 23 |
-
if hasattr(field_info, "metadata"):
|
| 24 |
-
metadata = field_info.metadata
|
| 25 |
-
for constraint in metadata:
|
| 26 |
-
if hasattr(constraint, "ge"):
|
| 27 |
-
config["min"] = constraint.ge
|
| 28 |
-
if hasattr(constraint, "le"):
|
| 29 |
-
config["max"] = constraint.le
|
| 30 |
-
if hasattr(constraint, "multiple_of"):
|
| 31 |
-
config["step"] = constraint.multiple_of
|
| 32 |
-
return config
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def _model_field_to_image_input(field_info: FieldInfo, **kwargs) -> NodeInput:
|
| 36 |
-
return IO.IMAGE, {
|
| 37 |
-
**_create_base_config(field_info),
|
| 38 |
-
**kwargs,
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def _model_field_to_string_input(field_info: FieldInfo, **kwargs) -> NodeInput:
|
| 43 |
-
return IO.STRING, {
|
| 44 |
-
**_create_base_config(field_info),
|
| 45 |
-
**kwargs,
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def _model_field_to_float_input(field_info: FieldInfo, **kwargs) -> NodeInput:
|
| 50 |
-
return IO.FLOAT, {
|
| 51 |
-
**_create_base_config(field_info),
|
| 52 |
-
**_get_number_constraints_config(field_info),
|
| 53 |
-
**kwargs,
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def _model_field_to_int_input(field_info: FieldInfo, **kwargs) -> NodeInput:
|
| 58 |
-
return IO.INT, {
|
| 59 |
-
**_create_base_config(field_info),
|
| 60 |
-
**_get_number_constraints_config(field_info),
|
| 61 |
-
**kwargs,
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def _model_field_to_combo_input(
|
| 66 |
-
field_info: FieldInfo, enum_type: type[Enum] = None, **kwargs
|
| 67 |
-
) -> NodeInput:
|
| 68 |
-
combo_config = {}
|
| 69 |
-
if enum_type is not None:
|
| 70 |
-
combo_config["options"] = [option.value for option in enum_type]
|
| 71 |
-
combo_config = {
|
| 72 |
-
**combo_config,
|
| 73 |
-
**_create_base_config(field_info),
|
| 74 |
-
**kwargs,
|
| 75 |
-
}
|
| 76 |
-
return IO.COMBO, combo_config
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def model_field_to_node_input(
|
| 80 |
-
input_type: IO, base_model: type[BaseModel], field_name: str, **kwargs
|
| 81 |
-
) -> NodeInput:
|
| 82 |
-
"""
|
| 83 |
-
Maps a field from a Pydantic model to a Comfy node input.
|
| 84 |
-
|
| 85 |
-
Args:
|
| 86 |
-
input_type: The type of the input.
|
| 87 |
-
base_model: The Pydantic model to map the field from.
|
| 88 |
-
field_name: The name of the field to map.
|
| 89 |
-
**kwargs: Additional key/values to include in the input options.
|
| 90 |
-
|
| 91 |
-
Note:
|
| 92 |
-
For combo inputs, pass an `Enum` to the `enum_type` keyword argument to populate the options automatically.
|
| 93 |
-
|
| 94 |
-
Example:
|
| 95 |
-
>>> model_field_to_node_input(IO.STRING, MyModel, "my_field", multiline=True)
|
| 96 |
-
>>> model_field_to_node_input(IO.COMBO, MyModel, "my_field", enum_type=MyEnum)
|
| 97 |
-
>>> model_field_to_node_input(IO.FLOAT, MyModel, "my_field", slider=True)
|
| 98 |
-
"""
|
| 99 |
-
field_info: FieldInfo = base_model.model_fields[field_name]
|
| 100 |
-
result: NodeInput
|
| 101 |
-
|
| 102 |
-
if input_type == IO.IMAGE:
|
| 103 |
-
result = _model_field_to_image_input(field_info, **kwargs)
|
| 104 |
-
elif input_type == IO.STRING:
|
| 105 |
-
result = _model_field_to_string_input(field_info, **kwargs)
|
| 106 |
-
elif input_type == IO.FLOAT:
|
| 107 |
-
result = _model_field_to_float_input(field_info, **kwargs)
|
| 108 |
-
elif input_type == IO.INT:
|
| 109 |
-
result = _model_field_to_int_input(field_info, **kwargs)
|
| 110 |
-
elif input_type == IO.COMBO:
|
| 111 |
-
result = _model_field_to_combo_input(field_info, **kwargs)
|
| 112 |
-
else:
|
| 113 |
-
message = f"Invalid input type: {input_type}"
|
| 114 |
-
raise ValueError(message)
|
| 115 |
-
|
| 116 |
-
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_bfl.py
DELETED
|
@@ -1,1075 +0,0 @@
|
|
| 1 |
-
import io
|
| 2 |
-
from inspect import cleandoc
|
| 3 |
-
from typing import Union, Optional
|
| 4 |
-
from comfy.comfy_types.node_typing import IO, ComfyNodeABC
|
| 5 |
-
from comfy_api_nodes.apis.bfl_api import (
|
| 6 |
-
BFLStatus,
|
| 7 |
-
BFLFluxExpandImageRequest,
|
| 8 |
-
BFLFluxFillImageRequest,
|
| 9 |
-
BFLFluxCannyImageRequest,
|
| 10 |
-
BFLFluxDepthImageRequest,
|
| 11 |
-
BFLFluxProGenerateRequest,
|
| 12 |
-
BFLFluxKontextProGenerateRequest,
|
| 13 |
-
BFLFluxProUltraGenerateRequest,
|
| 14 |
-
BFLFluxProGenerateResponse,
|
| 15 |
-
)
|
| 16 |
-
from comfy_api_nodes.apis.client import (
|
| 17 |
-
ApiEndpoint,
|
| 18 |
-
HttpMethod,
|
| 19 |
-
SynchronousOperation,
|
| 20 |
-
)
|
| 21 |
-
from comfy_api_nodes.apinode_utils import (
|
| 22 |
-
downscale_image_tensor,
|
| 23 |
-
validate_aspect_ratio,
|
| 24 |
-
process_image_response,
|
| 25 |
-
resize_mask_to_image,
|
| 26 |
-
validate_string,
|
| 27 |
-
)
|
| 28 |
-
|
| 29 |
-
import numpy as np
|
| 30 |
-
from PIL import Image
|
| 31 |
-
import requests
|
| 32 |
-
import torch
|
| 33 |
-
import base64
|
| 34 |
-
import time
|
| 35 |
-
from server import PromptServer
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def convert_mask_to_image(mask: torch.Tensor):
|
| 39 |
-
"""
|
| 40 |
-
Make mask have the expected amount of dims (4) and channels (3) to be recognized as an image.
|
| 41 |
-
"""
|
| 42 |
-
mask = mask.unsqueeze(-1)
|
| 43 |
-
mask = torch.cat([mask]*3, dim=-1)
|
| 44 |
-
return mask
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def handle_bfl_synchronous_operation(
|
| 48 |
-
operation: SynchronousOperation,
|
| 49 |
-
timeout_bfl_calls=360,
|
| 50 |
-
node_id: Union[str, None] = None,
|
| 51 |
-
):
|
| 52 |
-
response_api: BFLFluxProGenerateResponse = operation.execute()
|
| 53 |
-
return _poll_until_generated(
|
| 54 |
-
response_api.polling_url, timeout=timeout_bfl_calls, node_id=node_id
|
| 55 |
-
)
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def _poll_until_generated(
|
| 59 |
-
polling_url: str, timeout=360, node_id: Union[str, None] = None
|
| 60 |
-
):
|
| 61 |
-
# used bfl-comfy-nodes to verify code implementation:
|
| 62 |
-
# https://github.com/black-forest-labs/bfl-comfy-nodes/tree/main
|
| 63 |
-
start_time = time.time()
|
| 64 |
-
retries_404 = 0
|
| 65 |
-
max_retries_404 = 5
|
| 66 |
-
retry_404_seconds = 2
|
| 67 |
-
retry_202_seconds = 2
|
| 68 |
-
retry_pending_seconds = 1
|
| 69 |
-
request = requests.Request(method=HttpMethod.GET, url=polling_url)
|
| 70 |
-
# NOTE: should True loop be replaced with checking if workflow has been interrupted?
|
| 71 |
-
while True:
|
| 72 |
-
if node_id:
|
| 73 |
-
time_elapsed = time.time() - start_time
|
| 74 |
-
PromptServer.instance.send_progress_text(
|
| 75 |
-
f"Generating ({time_elapsed:.0f}s)", node_id
|
| 76 |
-
)
|
| 77 |
-
|
| 78 |
-
response = requests.Session().send(request.prepare())
|
| 79 |
-
if response.status_code == 200:
|
| 80 |
-
result = response.json()
|
| 81 |
-
if result["status"] == BFLStatus.ready:
|
| 82 |
-
img_url = result["result"]["sample"]
|
| 83 |
-
if node_id:
|
| 84 |
-
PromptServer.instance.send_progress_text(
|
| 85 |
-
f"Result URL: {img_url}", node_id
|
| 86 |
-
)
|
| 87 |
-
img_response = requests.get(img_url)
|
| 88 |
-
return process_image_response(img_response)
|
| 89 |
-
elif result["status"] in [
|
| 90 |
-
BFLStatus.request_moderated,
|
| 91 |
-
BFLStatus.content_moderated,
|
| 92 |
-
]:
|
| 93 |
-
status = result["status"]
|
| 94 |
-
raise Exception(
|
| 95 |
-
f"BFL API did not return an image due to: {status}."
|
| 96 |
-
)
|
| 97 |
-
elif result["status"] == BFLStatus.error:
|
| 98 |
-
raise Exception(f"BFL API encountered an error: {result}.")
|
| 99 |
-
elif result["status"] == BFLStatus.pending:
|
| 100 |
-
time.sleep(retry_pending_seconds)
|
| 101 |
-
continue
|
| 102 |
-
elif response.status_code == 404:
|
| 103 |
-
if retries_404 < max_retries_404:
|
| 104 |
-
retries_404 += 1
|
| 105 |
-
time.sleep(retry_404_seconds)
|
| 106 |
-
continue
|
| 107 |
-
raise Exception(
|
| 108 |
-
f"BFL API could not find task after {max_retries_404} tries."
|
| 109 |
-
)
|
| 110 |
-
elif response.status_code == 202:
|
| 111 |
-
time.sleep(retry_202_seconds)
|
| 112 |
-
elif time.time() - start_time > timeout:
|
| 113 |
-
raise Exception(
|
| 114 |
-
f"BFL API experienced a timeout; could not return request under {timeout} seconds."
|
| 115 |
-
)
|
| 116 |
-
else:
|
| 117 |
-
raise Exception(f"BFL API encountered an error: {response.json()}")
|
| 118 |
-
|
| 119 |
-
def convert_image_to_base64(image: torch.Tensor):
|
| 120 |
-
scaled_image = downscale_image_tensor(image, total_pixels=2048 * 2048)
|
| 121 |
-
# remove batch dimension if present
|
| 122 |
-
if len(scaled_image.shape) > 3:
|
| 123 |
-
scaled_image = scaled_image[0]
|
| 124 |
-
image_np = (scaled_image.numpy() * 255).astype(np.uint8)
|
| 125 |
-
img = Image.fromarray(image_np)
|
| 126 |
-
img_byte_arr = io.BytesIO()
|
| 127 |
-
img.save(img_byte_arr, format="PNG")
|
| 128 |
-
return base64.b64encode(img_byte_arr.getvalue()).decode()
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
class FluxProUltraImageNode(ComfyNodeABC):
|
| 132 |
-
"""
|
| 133 |
-
Generates images using Flux Pro 1.1 Ultra via api based on prompt and resolution.
|
| 134 |
-
"""
|
| 135 |
-
|
| 136 |
-
MINIMUM_RATIO = 1 / 4
|
| 137 |
-
MAXIMUM_RATIO = 4 / 1
|
| 138 |
-
MINIMUM_RATIO_STR = "1:4"
|
| 139 |
-
MAXIMUM_RATIO_STR = "4:1"
|
| 140 |
-
|
| 141 |
-
@classmethod
|
| 142 |
-
def INPUT_TYPES(s):
|
| 143 |
-
return {
|
| 144 |
-
"required": {
|
| 145 |
-
"prompt": (
|
| 146 |
-
IO.STRING,
|
| 147 |
-
{
|
| 148 |
-
"multiline": True,
|
| 149 |
-
"default": "",
|
| 150 |
-
"tooltip": "Prompt for the image generation",
|
| 151 |
-
},
|
| 152 |
-
),
|
| 153 |
-
"prompt_upsampling": (
|
| 154 |
-
IO.BOOLEAN,
|
| 155 |
-
{
|
| 156 |
-
"default": False,
|
| 157 |
-
"tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
|
| 158 |
-
},
|
| 159 |
-
),
|
| 160 |
-
"seed": (
|
| 161 |
-
IO.INT,
|
| 162 |
-
{
|
| 163 |
-
"default": 0,
|
| 164 |
-
"min": 0,
|
| 165 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 166 |
-
"control_after_generate": True,
|
| 167 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 168 |
-
},
|
| 169 |
-
),
|
| 170 |
-
"aspect_ratio": (
|
| 171 |
-
IO.STRING,
|
| 172 |
-
{
|
| 173 |
-
"default": "16:9",
|
| 174 |
-
"tooltip": "Aspect ratio of image; must be between 1:4 and 4:1.",
|
| 175 |
-
},
|
| 176 |
-
),
|
| 177 |
-
"raw": (
|
| 178 |
-
IO.BOOLEAN,
|
| 179 |
-
{
|
| 180 |
-
"default": False,
|
| 181 |
-
"tooltip": "When True, generate less processed, more natural-looking images.",
|
| 182 |
-
},
|
| 183 |
-
),
|
| 184 |
-
},
|
| 185 |
-
"optional": {
|
| 186 |
-
"image_prompt": (IO.IMAGE,),
|
| 187 |
-
"image_prompt_strength": (
|
| 188 |
-
IO.FLOAT,
|
| 189 |
-
{
|
| 190 |
-
"default": 0.1,
|
| 191 |
-
"min": 0.0,
|
| 192 |
-
"max": 1.0,
|
| 193 |
-
"step": 0.01,
|
| 194 |
-
"tooltip": "Blend between the prompt and the image prompt.",
|
| 195 |
-
},
|
| 196 |
-
),
|
| 197 |
-
},
|
| 198 |
-
"hidden": {
|
| 199 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 200 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 201 |
-
"unique_id": "UNIQUE_ID",
|
| 202 |
-
},
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
@classmethod
|
| 206 |
-
def VALIDATE_INPUTS(cls, aspect_ratio: str):
|
| 207 |
-
try:
|
| 208 |
-
validate_aspect_ratio(
|
| 209 |
-
aspect_ratio,
|
| 210 |
-
minimum_ratio=cls.MINIMUM_RATIO,
|
| 211 |
-
maximum_ratio=cls.MAXIMUM_RATIO,
|
| 212 |
-
minimum_ratio_str=cls.MINIMUM_RATIO_STR,
|
| 213 |
-
maximum_ratio_str=cls.MAXIMUM_RATIO_STR,
|
| 214 |
-
)
|
| 215 |
-
except Exception as e:
|
| 216 |
-
return str(e)
|
| 217 |
-
return True
|
| 218 |
-
|
| 219 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 220 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 221 |
-
FUNCTION = "api_call"
|
| 222 |
-
API_NODE = True
|
| 223 |
-
CATEGORY = "api node/image/BFL"
|
| 224 |
-
|
| 225 |
-
def api_call(
|
| 226 |
-
self,
|
| 227 |
-
prompt: str,
|
| 228 |
-
aspect_ratio: str,
|
| 229 |
-
prompt_upsampling=False,
|
| 230 |
-
raw=False,
|
| 231 |
-
seed=0,
|
| 232 |
-
image_prompt=None,
|
| 233 |
-
image_prompt_strength=0.1,
|
| 234 |
-
unique_id: Union[str, None] = None,
|
| 235 |
-
**kwargs,
|
| 236 |
-
):
|
| 237 |
-
if image_prompt is None:
|
| 238 |
-
validate_string(prompt, strip_whitespace=False)
|
| 239 |
-
operation = SynchronousOperation(
|
| 240 |
-
endpoint=ApiEndpoint(
|
| 241 |
-
path="/proxy/bfl/flux-pro-1.1-ultra/generate",
|
| 242 |
-
method=HttpMethod.POST,
|
| 243 |
-
request_model=BFLFluxProUltraGenerateRequest,
|
| 244 |
-
response_model=BFLFluxProGenerateResponse,
|
| 245 |
-
),
|
| 246 |
-
request=BFLFluxProUltraGenerateRequest(
|
| 247 |
-
prompt=prompt,
|
| 248 |
-
prompt_upsampling=prompt_upsampling,
|
| 249 |
-
seed=seed,
|
| 250 |
-
aspect_ratio=validate_aspect_ratio(
|
| 251 |
-
aspect_ratio,
|
| 252 |
-
minimum_ratio=self.MINIMUM_RATIO,
|
| 253 |
-
maximum_ratio=self.MAXIMUM_RATIO,
|
| 254 |
-
minimum_ratio_str=self.MINIMUM_RATIO_STR,
|
| 255 |
-
maximum_ratio_str=self.MAXIMUM_RATIO_STR,
|
| 256 |
-
),
|
| 257 |
-
raw=raw,
|
| 258 |
-
image_prompt=(
|
| 259 |
-
image_prompt
|
| 260 |
-
if image_prompt is None
|
| 261 |
-
else convert_image_to_base64(image_prompt)
|
| 262 |
-
),
|
| 263 |
-
image_prompt_strength=(
|
| 264 |
-
None if image_prompt is None else round(image_prompt_strength, 2)
|
| 265 |
-
),
|
| 266 |
-
),
|
| 267 |
-
auth_kwargs=kwargs,
|
| 268 |
-
)
|
| 269 |
-
output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
|
| 270 |
-
return (output_image,)
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
class FluxKontextProImageNode(ComfyNodeABC):
|
| 274 |
-
"""
|
| 275 |
-
Edits images using Flux.1 Kontext [pro] via api based on prompt and aspect ratio.
|
| 276 |
-
"""
|
| 277 |
-
|
| 278 |
-
MINIMUM_RATIO = 1 / 4
|
| 279 |
-
MAXIMUM_RATIO = 4 / 1
|
| 280 |
-
MINIMUM_RATIO_STR = "1:4"
|
| 281 |
-
MAXIMUM_RATIO_STR = "4:1"
|
| 282 |
-
|
| 283 |
-
@classmethod
|
| 284 |
-
def INPUT_TYPES(s):
|
| 285 |
-
return {
|
| 286 |
-
"required": {
|
| 287 |
-
"prompt": (
|
| 288 |
-
IO.STRING,
|
| 289 |
-
{
|
| 290 |
-
"multiline": True,
|
| 291 |
-
"default": "",
|
| 292 |
-
"tooltip": "Prompt for the image generation - specify what and how to edit.",
|
| 293 |
-
},
|
| 294 |
-
),
|
| 295 |
-
"aspect_ratio": (
|
| 296 |
-
IO.STRING,
|
| 297 |
-
{
|
| 298 |
-
"default": "16:9",
|
| 299 |
-
"tooltip": "Aspect ratio of image; must be between 1:4 and 4:1.",
|
| 300 |
-
},
|
| 301 |
-
),
|
| 302 |
-
"guidance": (
|
| 303 |
-
IO.FLOAT,
|
| 304 |
-
{
|
| 305 |
-
"default": 3.0,
|
| 306 |
-
"min": 0.1,
|
| 307 |
-
"max": 99.0,
|
| 308 |
-
"step": 0.1,
|
| 309 |
-
"tooltip": "Guidance strength for the image generation process"
|
| 310 |
-
},
|
| 311 |
-
),
|
| 312 |
-
"steps": (
|
| 313 |
-
IO.INT,
|
| 314 |
-
{
|
| 315 |
-
"default": 50,
|
| 316 |
-
"min": 1,
|
| 317 |
-
"max": 150,
|
| 318 |
-
"tooltip": "Number of steps for the image generation process"
|
| 319 |
-
},
|
| 320 |
-
),
|
| 321 |
-
"seed": (
|
| 322 |
-
IO.INT,
|
| 323 |
-
{
|
| 324 |
-
"default": 1234,
|
| 325 |
-
"min": 0,
|
| 326 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 327 |
-
"control_after_generate": True,
|
| 328 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 329 |
-
},
|
| 330 |
-
),
|
| 331 |
-
"prompt_upsampling": (
|
| 332 |
-
IO.BOOLEAN,
|
| 333 |
-
{
|
| 334 |
-
"default": False,
|
| 335 |
-
"tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
|
| 336 |
-
},
|
| 337 |
-
),
|
| 338 |
-
},
|
| 339 |
-
"optional": {
|
| 340 |
-
"input_image": (IO.IMAGE,),
|
| 341 |
-
},
|
| 342 |
-
"hidden": {
|
| 343 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 344 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 345 |
-
"unique_id": "UNIQUE_ID",
|
| 346 |
-
},
|
| 347 |
-
}
|
| 348 |
-
|
| 349 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 350 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 351 |
-
FUNCTION = "api_call"
|
| 352 |
-
API_NODE = True
|
| 353 |
-
CATEGORY = "api node/image/BFL"
|
| 354 |
-
|
| 355 |
-
BFL_PATH = "/proxy/bfl/flux-kontext-pro/generate"
|
| 356 |
-
|
| 357 |
-
def api_call(
|
| 358 |
-
self,
|
| 359 |
-
prompt: str,
|
| 360 |
-
aspect_ratio: str,
|
| 361 |
-
guidance: float,
|
| 362 |
-
steps: int,
|
| 363 |
-
input_image: Optional[torch.Tensor]=None,
|
| 364 |
-
seed=0,
|
| 365 |
-
prompt_upsampling=False,
|
| 366 |
-
unique_id: Union[str, None] = None,
|
| 367 |
-
**kwargs,
|
| 368 |
-
):
|
| 369 |
-
aspect_ratio = validate_aspect_ratio(
|
| 370 |
-
aspect_ratio,
|
| 371 |
-
minimum_ratio=self.MINIMUM_RATIO,
|
| 372 |
-
maximum_ratio=self.MAXIMUM_RATIO,
|
| 373 |
-
minimum_ratio_str=self.MINIMUM_RATIO_STR,
|
| 374 |
-
maximum_ratio_str=self.MAXIMUM_RATIO_STR,
|
| 375 |
-
)
|
| 376 |
-
if input_image is None:
|
| 377 |
-
validate_string(prompt, strip_whitespace=False)
|
| 378 |
-
operation = SynchronousOperation(
|
| 379 |
-
endpoint=ApiEndpoint(
|
| 380 |
-
path=self.BFL_PATH,
|
| 381 |
-
method=HttpMethod.POST,
|
| 382 |
-
request_model=BFLFluxKontextProGenerateRequest,
|
| 383 |
-
response_model=BFLFluxProGenerateResponse,
|
| 384 |
-
),
|
| 385 |
-
request=BFLFluxKontextProGenerateRequest(
|
| 386 |
-
prompt=prompt,
|
| 387 |
-
prompt_upsampling=prompt_upsampling,
|
| 388 |
-
guidance=round(guidance, 1),
|
| 389 |
-
steps=steps,
|
| 390 |
-
seed=seed,
|
| 391 |
-
aspect_ratio=aspect_ratio,
|
| 392 |
-
input_image=(
|
| 393 |
-
input_image
|
| 394 |
-
if input_image is None
|
| 395 |
-
else convert_image_to_base64(input_image)
|
| 396 |
-
)
|
| 397 |
-
),
|
| 398 |
-
auth_kwargs=kwargs,
|
| 399 |
-
)
|
| 400 |
-
output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
|
| 401 |
-
return (output_image,)
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
class FluxKontextMaxImageNode(FluxKontextProImageNode):
|
| 405 |
-
"""
|
| 406 |
-
Edits images using Flux.1 Kontext [max] via api based on prompt and aspect ratio.
|
| 407 |
-
"""
|
| 408 |
-
|
| 409 |
-
DESCRIPTION = cleandoc(__doc__ or "")
|
| 410 |
-
BFL_PATH = "/proxy/bfl/flux-kontext-max/generate"
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
class FluxProImageNode(ComfyNodeABC):
|
| 414 |
-
"""
|
| 415 |
-
Generates images synchronously based on prompt and resolution.
|
| 416 |
-
"""
|
| 417 |
-
|
| 418 |
-
@classmethod
|
| 419 |
-
def INPUT_TYPES(s):
|
| 420 |
-
return {
|
| 421 |
-
"required": {
|
| 422 |
-
"prompt": (
|
| 423 |
-
IO.STRING,
|
| 424 |
-
{
|
| 425 |
-
"multiline": True,
|
| 426 |
-
"default": "",
|
| 427 |
-
"tooltip": "Prompt for the image generation",
|
| 428 |
-
},
|
| 429 |
-
),
|
| 430 |
-
"prompt_upsampling": (
|
| 431 |
-
IO.BOOLEAN,
|
| 432 |
-
{
|
| 433 |
-
"default": False,
|
| 434 |
-
"tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
|
| 435 |
-
},
|
| 436 |
-
),
|
| 437 |
-
"width": (
|
| 438 |
-
IO.INT,
|
| 439 |
-
{
|
| 440 |
-
"default": 1024,
|
| 441 |
-
"min": 256,
|
| 442 |
-
"max": 1440,
|
| 443 |
-
"step": 32,
|
| 444 |
-
},
|
| 445 |
-
),
|
| 446 |
-
"height": (
|
| 447 |
-
IO.INT,
|
| 448 |
-
{
|
| 449 |
-
"default": 768,
|
| 450 |
-
"min": 256,
|
| 451 |
-
"max": 1440,
|
| 452 |
-
"step": 32,
|
| 453 |
-
},
|
| 454 |
-
),
|
| 455 |
-
"seed": (
|
| 456 |
-
IO.INT,
|
| 457 |
-
{
|
| 458 |
-
"default": 0,
|
| 459 |
-
"min": 0,
|
| 460 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 461 |
-
"control_after_generate": True,
|
| 462 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 463 |
-
},
|
| 464 |
-
),
|
| 465 |
-
},
|
| 466 |
-
"optional": {
|
| 467 |
-
"image_prompt": (IO.IMAGE,),
|
| 468 |
-
# "image_prompt_strength": (
|
| 469 |
-
# IO.FLOAT,
|
| 470 |
-
# {
|
| 471 |
-
# "default": 0.1,
|
| 472 |
-
# "min": 0.0,
|
| 473 |
-
# "max": 1.0,
|
| 474 |
-
# "step": 0.01,
|
| 475 |
-
# "tooltip": "Blend between the prompt and the image prompt.",
|
| 476 |
-
# },
|
| 477 |
-
# ),
|
| 478 |
-
},
|
| 479 |
-
"hidden": {
|
| 480 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 481 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 482 |
-
"unique_id": "UNIQUE_ID",
|
| 483 |
-
},
|
| 484 |
-
}
|
| 485 |
-
|
| 486 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 487 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 488 |
-
FUNCTION = "api_call"
|
| 489 |
-
API_NODE = True
|
| 490 |
-
CATEGORY = "api node/image/BFL"
|
| 491 |
-
|
| 492 |
-
def api_call(
|
| 493 |
-
self,
|
| 494 |
-
prompt: str,
|
| 495 |
-
prompt_upsampling,
|
| 496 |
-
width: int,
|
| 497 |
-
height: int,
|
| 498 |
-
seed=0,
|
| 499 |
-
image_prompt=None,
|
| 500 |
-
# image_prompt_strength=0.1,
|
| 501 |
-
unique_id: Union[str, None] = None,
|
| 502 |
-
**kwargs,
|
| 503 |
-
):
|
| 504 |
-
image_prompt = (
|
| 505 |
-
image_prompt
|
| 506 |
-
if image_prompt is None
|
| 507 |
-
else convert_image_to_base64(image_prompt)
|
| 508 |
-
)
|
| 509 |
-
|
| 510 |
-
operation = SynchronousOperation(
|
| 511 |
-
endpoint=ApiEndpoint(
|
| 512 |
-
path="/proxy/bfl/flux-pro-1.1/generate",
|
| 513 |
-
method=HttpMethod.POST,
|
| 514 |
-
request_model=BFLFluxProGenerateRequest,
|
| 515 |
-
response_model=BFLFluxProGenerateResponse,
|
| 516 |
-
),
|
| 517 |
-
request=BFLFluxProGenerateRequest(
|
| 518 |
-
prompt=prompt,
|
| 519 |
-
prompt_upsampling=prompt_upsampling,
|
| 520 |
-
width=width,
|
| 521 |
-
height=height,
|
| 522 |
-
seed=seed,
|
| 523 |
-
image_prompt=image_prompt,
|
| 524 |
-
),
|
| 525 |
-
auth_kwargs=kwargs,
|
| 526 |
-
)
|
| 527 |
-
output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
|
| 528 |
-
return (output_image,)
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
class FluxProExpandNode(ComfyNodeABC):
|
| 532 |
-
"""
|
| 533 |
-
Outpaints image based on prompt.
|
| 534 |
-
"""
|
| 535 |
-
|
| 536 |
-
@classmethod
|
| 537 |
-
def INPUT_TYPES(s):
|
| 538 |
-
return {
|
| 539 |
-
"required": {
|
| 540 |
-
"image": (IO.IMAGE,),
|
| 541 |
-
"prompt": (
|
| 542 |
-
IO.STRING,
|
| 543 |
-
{
|
| 544 |
-
"multiline": True,
|
| 545 |
-
"default": "",
|
| 546 |
-
"tooltip": "Prompt for the image generation",
|
| 547 |
-
},
|
| 548 |
-
),
|
| 549 |
-
"prompt_upsampling": (
|
| 550 |
-
IO.BOOLEAN,
|
| 551 |
-
{
|
| 552 |
-
"default": False,
|
| 553 |
-
"tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
|
| 554 |
-
},
|
| 555 |
-
),
|
| 556 |
-
"top": (
|
| 557 |
-
IO.INT,
|
| 558 |
-
{
|
| 559 |
-
"default": 0,
|
| 560 |
-
"min": 0,
|
| 561 |
-
"max": 2048,
|
| 562 |
-
"tooltip": "Number of pixels to expand at the top of the image"
|
| 563 |
-
},
|
| 564 |
-
),
|
| 565 |
-
"bottom": (
|
| 566 |
-
IO.INT,
|
| 567 |
-
{
|
| 568 |
-
"default": 0,
|
| 569 |
-
"min": 0,
|
| 570 |
-
"max": 2048,
|
| 571 |
-
"tooltip": "Number of pixels to expand at the bottom of the image"
|
| 572 |
-
},
|
| 573 |
-
),
|
| 574 |
-
"left": (
|
| 575 |
-
IO.INT,
|
| 576 |
-
{
|
| 577 |
-
"default": 0,
|
| 578 |
-
"min": 0,
|
| 579 |
-
"max": 2048,
|
| 580 |
-
"tooltip": "Number of pixels to expand at the left side of the image"
|
| 581 |
-
},
|
| 582 |
-
),
|
| 583 |
-
"right": (
|
| 584 |
-
IO.INT,
|
| 585 |
-
{
|
| 586 |
-
"default": 0,
|
| 587 |
-
"min": 0,
|
| 588 |
-
"max": 2048,
|
| 589 |
-
"tooltip": "Number of pixels to expand at the right side of the image"
|
| 590 |
-
},
|
| 591 |
-
),
|
| 592 |
-
"guidance": (
|
| 593 |
-
IO.FLOAT,
|
| 594 |
-
{
|
| 595 |
-
"default": 60,
|
| 596 |
-
"min": 1.5,
|
| 597 |
-
"max": 100,
|
| 598 |
-
"tooltip": "Guidance strength for the image generation process"
|
| 599 |
-
},
|
| 600 |
-
),
|
| 601 |
-
"steps": (
|
| 602 |
-
IO.INT,
|
| 603 |
-
{
|
| 604 |
-
"default": 50,
|
| 605 |
-
"min": 15,
|
| 606 |
-
"max": 50,
|
| 607 |
-
"tooltip": "Number of steps for the image generation process"
|
| 608 |
-
},
|
| 609 |
-
),
|
| 610 |
-
"seed": (
|
| 611 |
-
IO.INT,
|
| 612 |
-
{
|
| 613 |
-
"default": 0,
|
| 614 |
-
"min": 0,
|
| 615 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 616 |
-
"control_after_generate": True,
|
| 617 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 618 |
-
},
|
| 619 |
-
),
|
| 620 |
-
},
|
| 621 |
-
"optional": {},
|
| 622 |
-
"hidden": {
|
| 623 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 624 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 625 |
-
"unique_id": "UNIQUE_ID",
|
| 626 |
-
},
|
| 627 |
-
}
|
| 628 |
-
|
| 629 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 630 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 631 |
-
FUNCTION = "api_call"
|
| 632 |
-
API_NODE = True
|
| 633 |
-
CATEGORY = "api node/image/BFL"
|
| 634 |
-
|
| 635 |
-
def api_call(
|
| 636 |
-
self,
|
| 637 |
-
image: torch.Tensor,
|
| 638 |
-
prompt: str,
|
| 639 |
-
prompt_upsampling: bool,
|
| 640 |
-
top: int,
|
| 641 |
-
bottom: int,
|
| 642 |
-
left: int,
|
| 643 |
-
right: int,
|
| 644 |
-
steps: int,
|
| 645 |
-
guidance: float,
|
| 646 |
-
seed=0,
|
| 647 |
-
unique_id: Union[str, None] = None,
|
| 648 |
-
**kwargs,
|
| 649 |
-
):
|
| 650 |
-
image = convert_image_to_base64(image)
|
| 651 |
-
|
| 652 |
-
operation = SynchronousOperation(
|
| 653 |
-
endpoint=ApiEndpoint(
|
| 654 |
-
path="/proxy/bfl/flux-pro-1.0-expand/generate",
|
| 655 |
-
method=HttpMethod.POST,
|
| 656 |
-
request_model=BFLFluxExpandImageRequest,
|
| 657 |
-
response_model=BFLFluxProGenerateResponse,
|
| 658 |
-
),
|
| 659 |
-
request=BFLFluxExpandImageRequest(
|
| 660 |
-
prompt=prompt,
|
| 661 |
-
prompt_upsampling=prompt_upsampling,
|
| 662 |
-
top=top,
|
| 663 |
-
bottom=bottom,
|
| 664 |
-
left=left,
|
| 665 |
-
right=right,
|
| 666 |
-
steps=steps,
|
| 667 |
-
guidance=guidance,
|
| 668 |
-
seed=seed,
|
| 669 |
-
image=image,
|
| 670 |
-
),
|
| 671 |
-
auth_kwargs=kwargs,
|
| 672 |
-
)
|
| 673 |
-
output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
|
| 674 |
-
return (output_image,)
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
class FluxProFillNode(ComfyNodeABC):
|
| 679 |
-
"""
|
| 680 |
-
Inpaints image based on mask and prompt.
|
| 681 |
-
"""
|
| 682 |
-
|
| 683 |
-
@classmethod
|
| 684 |
-
def INPUT_TYPES(s):
|
| 685 |
-
return {
|
| 686 |
-
"required": {
|
| 687 |
-
"image": (IO.IMAGE,),
|
| 688 |
-
"mask": (IO.MASK,),
|
| 689 |
-
"prompt": (
|
| 690 |
-
IO.STRING,
|
| 691 |
-
{
|
| 692 |
-
"multiline": True,
|
| 693 |
-
"default": "",
|
| 694 |
-
"tooltip": "Prompt for the image generation",
|
| 695 |
-
},
|
| 696 |
-
),
|
| 697 |
-
"prompt_upsampling": (
|
| 698 |
-
IO.BOOLEAN,
|
| 699 |
-
{
|
| 700 |
-
"default": False,
|
| 701 |
-
"tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
|
| 702 |
-
},
|
| 703 |
-
),
|
| 704 |
-
"guidance": (
|
| 705 |
-
IO.FLOAT,
|
| 706 |
-
{
|
| 707 |
-
"default": 60,
|
| 708 |
-
"min": 1.5,
|
| 709 |
-
"max": 100,
|
| 710 |
-
"tooltip": "Guidance strength for the image generation process"
|
| 711 |
-
},
|
| 712 |
-
),
|
| 713 |
-
"steps": (
|
| 714 |
-
IO.INT,
|
| 715 |
-
{
|
| 716 |
-
"default": 50,
|
| 717 |
-
"min": 15,
|
| 718 |
-
"max": 50,
|
| 719 |
-
"tooltip": "Number of steps for the image generation process"
|
| 720 |
-
},
|
| 721 |
-
),
|
| 722 |
-
"seed": (
|
| 723 |
-
IO.INT,
|
| 724 |
-
{
|
| 725 |
-
"default": 0,
|
| 726 |
-
"min": 0,
|
| 727 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 728 |
-
"control_after_generate": True,
|
| 729 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 730 |
-
},
|
| 731 |
-
),
|
| 732 |
-
},
|
| 733 |
-
"optional": {},
|
| 734 |
-
"hidden": {
|
| 735 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 736 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 737 |
-
"unique_id": "UNIQUE_ID",
|
| 738 |
-
},
|
| 739 |
-
}
|
| 740 |
-
|
| 741 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 742 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 743 |
-
FUNCTION = "api_call"
|
| 744 |
-
API_NODE = True
|
| 745 |
-
CATEGORY = "api node/image/BFL"
|
| 746 |
-
|
| 747 |
-
def api_call(
|
| 748 |
-
self,
|
| 749 |
-
image: torch.Tensor,
|
| 750 |
-
mask: torch.Tensor,
|
| 751 |
-
prompt: str,
|
| 752 |
-
prompt_upsampling: bool,
|
| 753 |
-
steps: int,
|
| 754 |
-
guidance: float,
|
| 755 |
-
seed=0,
|
| 756 |
-
unique_id: Union[str, None] = None,
|
| 757 |
-
**kwargs,
|
| 758 |
-
):
|
| 759 |
-
# prepare mask
|
| 760 |
-
mask = resize_mask_to_image(mask, image)
|
| 761 |
-
mask = convert_image_to_base64(convert_mask_to_image(mask))
|
| 762 |
-
# make sure image will have alpha channel removed
|
| 763 |
-
image = convert_image_to_base64(image[:, :, :, :3])
|
| 764 |
-
|
| 765 |
-
operation = SynchronousOperation(
|
| 766 |
-
endpoint=ApiEndpoint(
|
| 767 |
-
path="/proxy/bfl/flux-pro-1.0-fill/generate",
|
| 768 |
-
method=HttpMethod.POST,
|
| 769 |
-
request_model=BFLFluxFillImageRequest,
|
| 770 |
-
response_model=BFLFluxProGenerateResponse,
|
| 771 |
-
),
|
| 772 |
-
request=BFLFluxFillImageRequest(
|
| 773 |
-
prompt=prompt,
|
| 774 |
-
prompt_upsampling=prompt_upsampling,
|
| 775 |
-
steps=steps,
|
| 776 |
-
guidance=guidance,
|
| 777 |
-
seed=seed,
|
| 778 |
-
image=image,
|
| 779 |
-
mask=mask,
|
| 780 |
-
),
|
| 781 |
-
auth_kwargs=kwargs,
|
| 782 |
-
)
|
| 783 |
-
output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
|
| 784 |
-
return (output_image,)
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
class FluxProCannyNode(ComfyNodeABC):
|
| 788 |
-
"""
|
| 789 |
-
Generate image using a control image (canny).
|
| 790 |
-
"""
|
| 791 |
-
|
| 792 |
-
@classmethod
|
| 793 |
-
def INPUT_TYPES(s):
|
| 794 |
-
return {
|
| 795 |
-
"required": {
|
| 796 |
-
"control_image": (IO.IMAGE,),
|
| 797 |
-
"prompt": (
|
| 798 |
-
IO.STRING,
|
| 799 |
-
{
|
| 800 |
-
"multiline": True,
|
| 801 |
-
"default": "",
|
| 802 |
-
"tooltip": "Prompt for the image generation",
|
| 803 |
-
},
|
| 804 |
-
),
|
| 805 |
-
"prompt_upsampling": (
|
| 806 |
-
IO.BOOLEAN,
|
| 807 |
-
{
|
| 808 |
-
"default": False,
|
| 809 |
-
"tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
|
| 810 |
-
},
|
| 811 |
-
),
|
| 812 |
-
"canny_low_threshold": (
|
| 813 |
-
IO.FLOAT,
|
| 814 |
-
{
|
| 815 |
-
"default": 0.1,
|
| 816 |
-
"min": 0.01,
|
| 817 |
-
"max": 0.99,
|
| 818 |
-
"step": 0.01,
|
| 819 |
-
"tooltip": "Low threshold for Canny edge detection; ignored if skip_processing is True"
|
| 820 |
-
},
|
| 821 |
-
),
|
| 822 |
-
"canny_high_threshold": (
|
| 823 |
-
IO.FLOAT,
|
| 824 |
-
{
|
| 825 |
-
"default": 0.4,
|
| 826 |
-
"min": 0.01,
|
| 827 |
-
"max": 0.99,
|
| 828 |
-
"step": 0.01,
|
| 829 |
-
"tooltip": "High threshold for Canny edge detection; ignored if skip_processing is True"
|
| 830 |
-
},
|
| 831 |
-
),
|
| 832 |
-
"skip_preprocessing": (
|
| 833 |
-
IO.BOOLEAN,
|
| 834 |
-
{
|
| 835 |
-
"default": False,
|
| 836 |
-
"tooltip": "Whether to skip preprocessing; set to True if control_image already is canny-fied, False if it is a raw image.",
|
| 837 |
-
},
|
| 838 |
-
),
|
| 839 |
-
"guidance": (
|
| 840 |
-
IO.FLOAT,
|
| 841 |
-
{
|
| 842 |
-
"default": 30,
|
| 843 |
-
"min": 1,
|
| 844 |
-
"max": 100,
|
| 845 |
-
"tooltip": "Guidance strength for the image generation process"
|
| 846 |
-
},
|
| 847 |
-
),
|
| 848 |
-
"steps": (
|
| 849 |
-
IO.INT,
|
| 850 |
-
{
|
| 851 |
-
"default": 50,
|
| 852 |
-
"min": 15,
|
| 853 |
-
"max": 50,
|
| 854 |
-
"tooltip": "Number of steps for the image generation process"
|
| 855 |
-
},
|
| 856 |
-
),
|
| 857 |
-
"seed": (
|
| 858 |
-
IO.INT,
|
| 859 |
-
{
|
| 860 |
-
"default": 0,
|
| 861 |
-
"min": 0,
|
| 862 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 863 |
-
"control_after_generate": True,
|
| 864 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 865 |
-
},
|
| 866 |
-
),
|
| 867 |
-
},
|
| 868 |
-
"optional": {},
|
| 869 |
-
"hidden": {
|
| 870 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 871 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 872 |
-
"unique_id": "UNIQUE_ID",
|
| 873 |
-
},
|
| 874 |
-
}
|
| 875 |
-
|
| 876 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 877 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 878 |
-
FUNCTION = "api_call"
|
| 879 |
-
API_NODE = True
|
| 880 |
-
CATEGORY = "api node/image/BFL"
|
| 881 |
-
|
| 882 |
-
def api_call(
|
| 883 |
-
self,
|
| 884 |
-
control_image: torch.Tensor,
|
| 885 |
-
prompt: str,
|
| 886 |
-
prompt_upsampling: bool,
|
| 887 |
-
canny_low_threshold: float,
|
| 888 |
-
canny_high_threshold: float,
|
| 889 |
-
skip_preprocessing: bool,
|
| 890 |
-
steps: int,
|
| 891 |
-
guidance: float,
|
| 892 |
-
seed=0,
|
| 893 |
-
unique_id: Union[str, None] = None,
|
| 894 |
-
**kwargs,
|
| 895 |
-
):
|
| 896 |
-
control_image = convert_image_to_base64(control_image[:, :, :, :3])
|
| 897 |
-
preprocessed_image = None
|
| 898 |
-
|
| 899 |
-
# scale canny threshold between 0-500, to match BFL's API
|
| 900 |
-
def scale_value(value: float, min_val=0, max_val=500):
|
| 901 |
-
return min_val + value * (max_val - min_val)
|
| 902 |
-
canny_low_threshold = int(round(scale_value(canny_low_threshold)))
|
| 903 |
-
canny_high_threshold = int(round(scale_value(canny_high_threshold)))
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
if skip_preprocessing:
|
| 907 |
-
preprocessed_image = control_image
|
| 908 |
-
control_image = None
|
| 909 |
-
canny_low_threshold = None
|
| 910 |
-
canny_high_threshold = None
|
| 911 |
-
|
| 912 |
-
operation = SynchronousOperation(
|
| 913 |
-
endpoint=ApiEndpoint(
|
| 914 |
-
path="/proxy/bfl/flux-pro-1.0-canny/generate",
|
| 915 |
-
method=HttpMethod.POST,
|
| 916 |
-
request_model=BFLFluxCannyImageRequest,
|
| 917 |
-
response_model=BFLFluxProGenerateResponse,
|
| 918 |
-
),
|
| 919 |
-
request=BFLFluxCannyImageRequest(
|
| 920 |
-
prompt=prompt,
|
| 921 |
-
prompt_upsampling=prompt_upsampling,
|
| 922 |
-
steps=steps,
|
| 923 |
-
guidance=guidance,
|
| 924 |
-
seed=seed,
|
| 925 |
-
control_image=control_image,
|
| 926 |
-
canny_low_threshold=canny_low_threshold,
|
| 927 |
-
canny_high_threshold=canny_high_threshold,
|
| 928 |
-
preprocessed_image=preprocessed_image,
|
| 929 |
-
),
|
| 930 |
-
auth_kwargs=kwargs,
|
| 931 |
-
)
|
| 932 |
-
output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
|
| 933 |
-
return (output_image,)
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
class FluxProDepthNode(ComfyNodeABC):
|
| 937 |
-
"""
|
| 938 |
-
Generate image using a control image (depth).
|
| 939 |
-
"""
|
| 940 |
-
|
| 941 |
-
@classmethod
|
| 942 |
-
def INPUT_TYPES(s):
|
| 943 |
-
return {
|
| 944 |
-
"required": {
|
| 945 |
-
"control_image": (IO.IMAGE,),
|
| 946 |
-
"prompt": (
|
| 947 |
-
IO.STRING,
|
| 948 |
-
{
|
| 949 |
-
"multiline": True,
|
| 950 |
-
"default": "",
|
| 951 |
-
"tooltip": "Prompt for the image generation",
|
| 952 |
-
},
|
| 953 |
-
),
|
| 954 |
-
"prompt_upsampling": (
|
| 955 |
-
IO.BOOLEAN,
|
| 956 |
-
{
|
| 957 |
-
"default": False,
|
| 958 |
-
"tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
|
| 959 |
-
},
|
| 960 |
-
),
|
| 961 |
-
"skip_preprocessing": (
|
| 962 |
-
IO.BOOLEAN,
|
| 963 |
-
{
|
| 964 |
-
"default": False,
|
| 965 |
-
"tooltip": "Whether to skip preprocessing; set to True if control_image already is depth-ified, False if it is a raw image.",
|
| 966 |
-
},
|
| 967 |
-
),
|
| 968 |
-
"guidance": (
|
| 969 |
-
IO.FLOAT,
|
| 970 |
-
{
|
| 971 |
-
"default": 15,
|
| 972 |
-
"min": 1,
|
| 973 |
-
"max": 100,
|
| 974 |
-
"tooltip": "Guidance strength for the image generation process"
|
| 975 |
-
},
|
| 976 |
-
),
|
| 977 |
-
"steps": (
|
| 978 |
-
IO.INT,
|
| 979 |
-
{
|
| 980 |
-
"default": 50,
|
| 981 |
-
"min": 15,
|
| 982 |
-
"max": 50,
|
| 983 |
-
"tooltip": "Number of steps for the image generation process"
|
| 984 |
-
},
|
| 985 |
-
),
|
| 986 |
-
"seed": (
|
| 987 |
-
IO.INT,
|
| 988 |
-
{
|
| 989 |
-
"default": 0,
|
| 990 |
-
"min": 0,
|
| 991 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 992 |
-
"control_after_generate": True,
|
| 993 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 994 |
-
},
|
| 995 |
-
),
|
| 996 |
-
},
|
| 997 |
-
"optional": {},
|
| 998 |
-
"hidden": {
|
| 999 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 1000 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 1001 |
-
"unique_id": "UNIQUE_ID",
|
| 1002 |
-
},
|
| 1003 |
-
}
|
| 1004 |
-
|
| 1005 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 1006 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 1007 |
-
FUNCTION = "api_call"
|
| 1008 |
-
API_NODE = True
|
| 1009 |
-
CATEGORY = "api node/image/BFL"
|
| 1010 |
-
|
| 1011 |
-
def api_call(
|
| 1012 |
-
self,
|
| 1013 |
-
control_image: torch.Tensor,
|
| 1014 |
-
prompt: str,
|
| 1015 |
-
prompt_upsampling: bool,
|
| 1016 |
-
skip_preprocessing: bool,
|
| 1017 |
-
steps: int,
|
| 1018 |
-
guidance: float,
|
| 1019 |
-
seed=0,
|
| 1020 |
-
unique_id: Union[str, None] = None,
|
| 1021 |
-
**kwargs,
|
| 1022 |
-
):
|
| 1023 |
-
control_image = convert_image_to_base64(control_image[:,:,:,:3])
|
| 1024 |
-
preprocessed_image = None
|
| 1025 |
-
|
| 1026 |
-
if skip_preprocessing:
|
| 1027 |
-
preprocessed_image = control_image
|
| 1028 |
-
control_image = None
|
| 1029 |
-
|
| 1030 |
-
operation = SynchronousOperation(
|
| 1031 |
-
endpoint=ApiEndpoint(
|
| 1032 |
-
path="/proxy/bfl/flux-pro-1.0-depth/generate",
|
| 1033 |
-
method=HttpMethod.POST,
|
| 1034 |
-
request_model=BFLFluxDepthImageRequest,
|
| 1035 |
-
response_model=BFLFluxProGenerateResponse,
|
| 1036 |
-
),
|
| 1037 |
-
request=BFLFluxDepthImageRequest(
|
| 1038 |
-
prompt=prompt,
|
| 1039 |
-
prompt_upsampling=prompt_upsampling,
|
| 1040 |
-
steps=steps,
|
| 1041 |
-
guidance=guidance,
|
| 1042 |
-
seed=seed,
|
| 1043 |
-
control_image=control_image,
|
| 1044 |
-
preprocessed_image=preprocessed_image,
|
| 1045 |
-
),
|
| 1046 |
-
auth_kwargs=kwargs,
|
| 1047 |
-
)
|
| 1048 |
-
output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
|
| 1049 |
-
return (output_image,)
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
# A dictionary that contains all nodes you want to export with their names
|
| 1053 |
-
# NOTE: names should be globally unique
|
| 1054 |
-
NODE_CLASS_MAPPINGS = {
|
| 1055 |
-
"FluxProUltraImageNode": FluxProUltraImageNode,
|
| 1056 |
-
# "FluxProImageNode": FluxProImageNode,
|
| 1057 |
-
"FluxKontextProImageNode": FluxKontextProImageNode,
|
| 1058 |
-
"FluxKontextMaxImageNode": FluxKontextMaxImageNode,
|
| 1059 |
-
"FluxProExpandNode": FluxProExpandNode,
|
| 1060 |
-
"FluxProFillNode": FluxProFillNode,
|
| 1061 |
-
"FluxProCannyNode": FluxProCannyNode,
|
| 1062 |
-
"FluxProDepthNode": FluxProDepthNode,
|
| 1063 |
-
}
|
| 1064 |
-
|
| 1065 |
-
# A dictionary that contains the friendly/humanly readable titles for the nodes
|
| 1066 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 1067 |
-
"FluxProUltraImageNode": "Flux 1.1 [pro] Ultra Image",
|
| 1068 |
-
# "FluxProImageNode": "Flux 1.1 [pro] Image",
|
| 1069 |
-
"FluxKontextProImageNode": "Flux.1 Kontext [pro] Image",
|
| 1070 |
-
"FluxKontextMaxImageNode": "Flux.1 Kontext [max] Image",
|
| 1071 |
-
"FluxProExpandNode": "Flux.1 Expand Image",
|
| 1072 |
-
"FluxProFillNode": "Flux.1 Fill Image",
|
| 1073 |
-
"FluxProCannyNode": "Flux.1 Canny Control Image",
|
| 1074 |
-
"FluxProDepthNode": "Flux.1 Depth Control Image",
|
| 1075 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_gemini.py
DELETED
|
@@ -1,446 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
API Nodes for Gemini Multimodal LLM Usage via Remote API
|
| 3 |
-
See: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
import os
|
| 7 |
-
from enum import Enum
|
| 8 |
-
from typing import Optional, Literal
|
| 9 |
-
|
| 10 |
-
import torch
|
| 11 |
-
|
| 12 |
-
import folder_paths
|
| 13 |
-
from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeDict
|
| 14 |
-
from server import PromptServer
|
| 15 |
-
from comfy_api_nodes.apis import (
|
| 16 |
-
GeminiContent,
|
| 17 |
-
GeminiGenerateContentRequest,
|
| 18 |
-
GeminiGenerateContentResponse,
|
| 19 |
-
GeminiInlineData,
|
| 20 |
-
GeminiPart,
|
| 21 |
-
GeminiMimeType,
|
| 22 |
-
)
|
| 23 |
-
from comfy_api_nodes.apis.client import (
|
| 24 |
-
ApiEndpoint,
|
| 25 |
-
HttpMethod,
|
| 26 |
-
SynchronousOperation,
|
| 27 |
-
)
|
| 28 |
-
from comfy_api_nodes.apinode_utils import (
|
| 29 |
-
validate_string,
|
| 30 |
-
audio_to_base64_string,
|
| 31 |
-
video_to_base64_string,
|
| 32 |
-
tensor_to_base64_string,
|
| 33 |
-
)
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
GEMINI_BASE_ENDPOINT = "/proxy/vertexai/gemini"
|
| 37 |
-
GEMINI_MAX_INPUT_FILE_SIZE = 20 * 1024 * 1024 # 20 MB
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
class GeminiModel(str, Enum):
|
| 41 |
-
"""
|
| 42 |
-
Gemini Model Names allowed by comfy-api
|
| 43 |
-
"""
|
| 44 |
-
|
| 45 |
-
gemini_2_5_pro_preview_05_06 = "gemini-2.5-pro-preview-05-06"
|
| 46 |
-
gemini_2_5_flash_preview_04_17 = "gemini-2.5-flash-preview-04-17"
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def get_gemini_endpoint(
|
| 50 |
-
model: GeminiModel,
|
| 51 |
-
) -> ApiEndpoint[GeminiGenerateContentRequest, GeminiGenerateContentResponse]:
|
| 52 |
-
"""
|
| 53 |
-
Get the API endpoint for a given Gemini model.
|
| 54 |
-
|
| 55 |
-
Args:
|
| 56 |
-
model: The Gemini model to use, either as enum or string value.
|
| 57 |
-
|
| 58 |
-
Returns:
|
| 59 |
-
ApiEndpoint configured for the specific Gemini model.
|
| 60 |
-
"""
|
| 61 |
-
if isinstance(model, str):
|
| 62 |
-
model = GeminiModel(model)
|
| 63 |
-
return ApiEndpoint(
|
| 64 |
-
path=f"{GEMINI_BASE_ENDPOINT}/{model.value}",
|
| 65 |
-
method=HttpMethod.POST,
|
| 66 |
-
request_model=GeminiGenerateContentRequest,
|
| 67 |
-
response_model=GeminiGenerateContentResponse,
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
class GeminiNode(ComfyNodeABC):
|
| 72 |
-
"""
|
| 73 |
-
Node to generate text responses from a Gemini model.
|
| 74 |
-
|
| 75 |
-
This node allows users to interact with Google's Gemini AI models, providing
|
| 76 |
-
multimodal inputs (text, images, audio, video, files) to generate coherent
|
| 77 |
-
text responses. The node works with the latest Gemini models, handling the
|
| 78 |
-
API communication and response parsing.
|
| 79 |
-
"""
|
| 80 |
-
|
| 81 |
-
@classmethod
|
| 82 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 83 |
-
return {
|
| 84 |
-
"required": {
|
| 85 |
-
"prompt": (
|
| 86 |
-
IO.STRING,
|
| 87 |
-
{
|
| 88 |
-
"multiline": True,
|
| 89 |
-
"default": "",
|
| 90 |
-
"tooltip": "Text inputs to the model, used to generate a response. You can include detailed instructions, questions, or context for the model.",
|
| 91 |
-
},
|
| 92 |
-
),
|
| 93 |
-
"model": (
|
| 94 |
-
IO.COMBO,
|
| 95 |
-
{
|
| 96 |
-
"tooltip": "The Gemini model to use for generating responses.",
|
| 97 |
-
"options": [model.value for model in GeminiModel],
|
| 98 |
-
"default": GeminiModel.gemini_2_5_pro_preview_05_06.value,
|
| 99 |
-
},
|
| 100 |
-
),
|
| 101 |
-
"seed": (
|
| 102 |
-
IO.INT,
|
| 103 |
-
{
|
| 104 |
-
"default": 42,
|
| 105 |
-
"min": 0,
|
| 106 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 107 |
-
"control_after_generate": True,
|
| 108 |
-
"tooltip": "When seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used.",
|
| 109 |
-
},
|
| 110 |
-
),
|
| 111 |
-
},
|
| 112 |
-
"optional": {
|
| 113 |
-
"images": (
|
| 114 |
-
IO.IMAGE,
|
| 115 |
-
{
|
| 116 |
-
"default": None,
|
| 117 |
-
"tooltip": "Optional image(s) to use as context for the model. To include multiple images, you can use the Batch Images node.",
|
| 118 |
-
},
|
| 119 |
-
),
|
| 120 |
-
"audio": (
|
| 121 |
-
IO.AUDIO,
|
| 122 |
-
{
|
| 123 |
-
"tooltip": "Optional audio to use as context for the model.",
|
| 124 |
-
"default": None,
|
| 125 |
-
},
|
| 126 |
-
),
|
| 127 |
-
"video": (
|
| 128 |
-
IO.VIDEO,
|
| 129 |
-
{
|
| 130 |
-
"tooltip": "Optional video to use as context for the model.",
|
| 131 |
-
"default": None,
|
| 132 |
-
},
|
| 133 |
-
),
|
| 134 |
-
"files": (
|
| 135 |
-
"GEMINI_INPUT_FILES",
|
| 136 |
-
{
|
| 137 |
-
"default": None,
|
| 138 |
-
"tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node.",
|
| 139 |
-
},
|
| 140 |
-
),
|
| 141 |
-
},
|
| 142 |
-
"hidden": {
|
| 143 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 144 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 145 |
-
"unique_id": "UNIQUE_ID",
|
| 146 |
-
},
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
DESCRIPTION = "Generate text responses with Google's Gemini AI model. You can provide multiple types of inputs (text, images, audio, video) as context for generating more relevant and meaningful responses."
|
| 150 |
-
RETURN_TYPES = ("STRING",)
|
| 151 |
-
FUNCTION = "api_call"
|
| 152 |
-
CATEGORY = "api node/text/Gemini"
|
| 153 |
-
API_NODE = True
|
| 154 |
-
|
| 155 |
-
def get_parts_from_response(
|
| 156 |
-
self, response: GeminiGenerateContentResponse
|
| 157 |
-
) -> list[GeminiPart]:
|
| 158 |
-
"""
|
| 159 |
-
Extract all parts from the Gemini API response.
|
| 160 |
-
|
| 161 |
-
Args:
|
| 162 |
-
response: The API response from Gemini.
|
| 163 |
-
|
| 164 |
-
Returns:
|
| 165 |
-
List of response parts from the first candidate.
|
| 166 |
-
"""
|
| 167 |
-
return response.candidates[0].content.parts
|
| 168 |
-
|
| 169 |
-
def get_parts_by_type(
|
| 170 |
-
self, response: GeminiGenerateContentResponse, part_type: Literal["text"] | str
|
| 171 |
-
) -> list[GeminiPart]:
|
| 172 |
-
"""
|
| 173 |
-
Filter response parts by their type.
|
| 174 |
-
|
| 175 |
-
Args:
|
| 176 |
-
response: The API response from Gemini.
|
| 177 |
-
part_type: Type of parts to extract ("text" or a MIME type).
|
| 178 |
-
|
| 179 |
-
Returns:
|
| 180 |
-
List of response parts matching the requested type.
|
| 181 |
-
"""
|
| 182 |
-
parts = []
|
| 183 |
-
for part in self.get_parts_from_response(response):
|
| 184 |
-
if part_type == "text" and hasattr(part, "text") and part.text:
|
| 185 |
-
parts.append(part)
|
| 186 |
-
elif (
|
| 187 |
-
hasattr(part, "inlineData")
|
| 188 |
-
and part.inlineData
|
| 189 |
-
and part.inlineData.mimeType == part_type
|
| 190 |
-
):
|
| 191 |
-
parts.append(part)
|
| 192 |
-
# Skip parts that don't match the requested type
|
| 193 |
-
return parts
|
| 194 |
-
|
| 195 |
-
def get_text_from_response(self, response: GeminiGenerateContentResponse) -> str:
|
| 196 |
-
"""
|
| 197 |
-
Extract and concatenate all text parts from the response.
|
| 198 |
-
|
| 199 |
-
Args:
|
| 200 |
-
response: The API response from Gemini.
|
| 201 |
-
|
| 202 |
-
Returns:
|
| 203 |
-
Combined text from all text parts in the response.
|
| 204 |
-
"""
|
| 205 |
-
parts = self.get_parts_by_type(response, "text")
|
| 206 |
-
return "\n".join([part.text for part in parts])
|
| 207 |
-
|
| 208 |
-
def create_video_parts(self, video_input: IO.VIDEO, **kwargs) -> list[GeminiPart]:
|
| 209 |
-
"""
|
| 210 |
-
Convert video input to Gemini API compatible parts.
|
| 211 |
-
|
| 212 |
-
Args:
|
| 213 |
-
video_input: Video tensor from ComfyUI.
|
| 214 |
-
**kwargs: Additional arguments to pass to the conversion function.
|
| 215 |
-
|
| 216 |
-
Returns:
|
| 217 |
-
List of GeminiPart objects containing the encoded video.
|
| 218 |
-
"""
|
| 219 |
-
from comfy_api.util import VideoContainer, VideoCodec
|
| 220 |
-
base_64_string = video_to_base64_string(
|
| 221 |
-
video_input,
|
| 222 |
-
container_format=VideoContainer.MP4,
|
| 223 |
-
codec=VideoCodec.H264
|
| 224 |
-
)
|
| 225 |
-
return [
|
| 226 |
-
GeminiPart(
|
| 227 |
-
inlineData=GeminiInlineData(
|
| 228 |
-
mimeType=GeminiMimeType.video_mp4,
|
| 229 |
-
data=base_64_string,
|
| 230 |
-
)
|
| 231 |
-
)
|
| 232 |
-
]
|
| 233 |
-
|
| 234 |
-
def create_audio_parts(self, audio_input: IO.AUDIO) -> list[GeminiPart]:
|
| 235 |
-
"""
|
| 236 |
-
Convert audio input to Gemini API compatible parts.
|
| 237 |
-
|
| 238 |
-
Args:
|
| 239 |
-
audio_input: Audio input from ComfyUI, containing waveform tensor and sample rate.
|
| 240 |
-
|
| 241 |
-
Returns:
|
| 242 |
-
List of GeminiPart objects containing the encoded audio.
|
| 243 |
-
"""
|
| 244 |
-
audio_parts: list[GeminiPart] = []
|
| 245 |
-
for batch_index in range(audio_input["waveform"].shape[0]):
|
| 246 |
-
# Recreate an IO.AUDIO object for the given batch dimension index
|
| 247 |
-
audio_at_index = {
|
| 248 |
-
"waveform": audio_input["waveform"][batch_index].unsqueeze(0),
|
| 249 |
-
"sample_rate": audio_input["sample_rate"],
|
| 250 |
-
}
|
| 251 |
-
# Convert to MP3 format for compatibility with Gemini API
|
| 252 |
-
audio_bytes = audio_to_base64_string(
|
| 253 |
-
audio_at_index,
|
| 254 |
-
container_format="mp3",
|
| 255 |
-
codec_name="libmp3lame",
|
| 256 |
-
)
|
| 257 |
-
audio_parts.append(
|
| 258 |
-
GeminiPart(
|
| 259 |
-
inlineData=GeminiInlineData(
|
| 260 |
-
mimeType=GeminiMimeType.audio_mp3,
|
| 261 |
-
data=audio_bytes,
|
| 262 |
-
)
|
| 263 |
-
)
|
| 264 |
-
)
|
| 265 |
-
return audio_parts
|
| 266 |
-
|
| 267 |
-
def create_image_parts(self, image_input: torch.Tensor) -> list[GeminiPart]:
|
| 268 |
-
"""
|
| 269 |
-
Convert image tensor input to Gemini API compatible parts.
|
| 270 |
-
|
| 271 |
-
Args:
|
| 272 |
-
image_input: Batch of image tensors from ComfyUI.
|
| 273 |
-
|
| 274 |
-
Returns:
|
| 275 |
-
List of GeminiPart objects containing the encoded images.
|
| 276 |
-
"""
|
| 277 |
-
image_parts: list[GeminiPart] = []
|
| 278 |
-
for image_index in range(image_input.shape[0]):
|
| 279 |
-
image_as_b64 = tensor_to_base64_string(
|
| 280 |
-
image_input[image_index].unsqueeze(0)
|
| 281 |
-
)
|
| 282 |
-
image_parts.append(
|
| 283 |
-
GeminiPart(
|
| 284 |
-
inlineData=GeminiInlineData(
|
| 285 |
-
mimeType=GeminiMimeType.image_png,
|
| 286 |
-
data=image_as_b64,
|
| 287 |
-
)
|
| 288 |
-
)
|
| 289 |
-
)
|
| 290 |
-
return image_parts
|
| 291 |
-
|
| 292 |
-
def create_text_part(self, text: str) -> GeminiPart:
|
| 293 |
-
"""
|
| 294 |
-
Create a text part for the Gemini API request.
|
| 295 |
-
|
| 296 |
-
Args:
|
| 297 |
-
text: The text content to include in the request.
|
| 298 |
-
|
| 299 |
-
Returns:
|
| 300 |
-
A GeminiPart object with the text content.
|
| 301 |
-
"""
|
| 302 |
-
return GeminiPart(text=text)
|
| 303 |
-
|
| 304 |
-
def api_call(
|
| 305 |
-
self,
|
| 306 |
-
prompt: str,
|
| 307 |
-
model: GeminiModel,
|
| 308 |
-
images: Optional[IO.IMAGE] = None,
|
| 309 |
-
audio: Optional[IO.AUDIO] = None,
|
| 310 |
-
video: Optional[IO.VIDEO] = None,
|
| 311 |
-
files: Optional[list[GeminiPart]] = None,
|
| 312 |
-
unique_id: Optional[str] = None,
|
| 313 |
-
**kwargs,
|
| 314 |
-
) -> tuple[str]:
|
| 315 |
-
# Validate inputs
|
| 316 |
-
validate_string(prompt, strip_whitespace=False)
|
| 317 |
-
|
| 318 |
-
# Create parts list with text prompt as the first part
|
| 319 |
-
parts: list[GeminiPart] = [self.create_text_part(prompt)]
|
| 320 |
-
|
| 321 |
-
# Add other modal parts
|
| 322 |
-
if images is not None:
|
| 323 |
-
image_parts = self.create_image_parts(images)
|
| 324 |
-
parts.extend(image_parts)
|
| 325 |
-
if audio is not None:
|
| 326 |
-
parts.extend(self.create_audio_parts(audio))
|
| 327 |
-
if video is not None:
|
| 328 |
-
parts.extend(self.create_video_parts(video))
|
| 329 |
-
if files is not None:
|
| 330 |
-
parts.extend(files)
|
| 331 |
-
|
| 332 |
-
# Create response
|
| 333 |
-
response = SynchronousOperation(
|
| 334 |
-
endpoint=get_gemini_endpoint(model),
|
| 335 |
-
request=GeminiGenerateContentRequest(
|
| 336 |
-
contents=[
|
| 337 |
-
GeminiContent(
|
| 338 |
-
role="user",
|
| 339 |
-
parts=parts,
|
| 340 |
-
)
|
| 341 |
-
]
|
| 342 |
-
),
|
| 343 |
-
auth_kwargs=kwargs,
|
| 344 |
-
).execute()
|
| 345 |
-
|
| 346 |
-
# Get result output
|
| 347 |
-
output_text = self.get_text_from_response(response)
|
| 348 |
-
if unique_id and output_text:
|
| 349 |
-
PromptServer.instance.send_progress_text(output_text, node_id=unique_id)
|
| 350 |
-
|
| 351 |
-
return (output_text or "Empty response from Gemini model...",)
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
class GeminiInputFiles(ComfyNodeABC):
|
| 355 |
-
"""
|
| 356 |
-
Loads and formats input files for use with the Gemini API.
|
| 357 |
-
|
| 358 |
-
This node allows users to include text (.txt) and PDF (.pdf) files as input
|
| 359 |
-
context for the Gemini model. Files are converted to the appropriate format
|
| 360 |
-
required by the API and can be chained together to include multiple files
|
| 361 |
-
in a single request.
|
| 362 |
-
"""
|
| 363 |
-
|
| 364 |
-
@classmethod
|
| 365 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 366 |
-
"""
|
| 367 |
-
For details about the supported file input types, see:
|
| 368 |
-
https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference
|
| 369 |
-
"""
|
| 370 |
-
input_dir = folder_paths.get_input_directory()
|
| 371 |
-
input_files = [
|
| 372 |
-
f
|
| 373 |
-
for f in os.scandir(input_dir)
|
| 374 |
-
if f.is_file()
|
| 375 |
-
and (f.name.endswith(".txt") or f.name.endswith(".pdf"))
|
| 376 |
-
and f.stat().st_size < GEMINI_MAX_INPUT_FILE_SIZE
|
| 377 |
-
]
|
| 378 |
-
input_files = sorted(input_files, key=lambda x: x.name)
|
| 379 |
-
input_files = [f.name for f in input_files]
|
| 380 |
-
return {
|
| 381 |
-
"required": {
|
| 382 |
-
"file": (
|
| 383 |
-
IO.COMBO,
|
| 384 |
-
{
|
| 385 |
-
"tooltip": "Input files to include as context for the model. Only accepts text (.txt) and PDF (.pdf) files for now.",
|
| 386 |
-
"options": input_files,
|
| 387 |
-
"default": input_files[0] if input_files else None,
|
| 388 |
-
},
|
| 389 |
-
),
|
| 390 |
-
},
|
| 391 |
-
"optional": {
|
| 392 |
-
"GEMINI_INPUT_FILES": (
|
| 393 |
-
"GEMINI_INPUT_FILES",
|
| 394 |
-
{
|
| 395 |
-
"tooltip": "An optional additional file(s) to batch together with the file loaded from this node. Allows chaining of input files so that a single message can include multiple input files.",
|
| 396 |
-
"default": None,
|
| 397 |
-
},
|
| 398 |
-
),
|
| 399 |
-
},
|
| 400 |
-
}
|
| 401 |
-
|
| 402 |
-
DESCRIPTION = "Loads and prepares input files to include as inputs for Gemini LLM nodes. The files will be read by the Gemini model when generating a response. The contents of the text file count toward the token limit. 🛈 TIP: Can be chained together with other Gemini Input File nodes."
|
| 403 |
-
RETURN_TYPES = ("GEMINI_INPUT_FILES",)
|
| 404 |
-
FUNCTION = "prepare_files"
|
| 405 |
-
CATEGORY = "api node/text/Gemini"
|
| 406 |
-
|
| 407 |
-
def create_file_part(self, file_path: str) -> GeminiPart:
|
| 408 |
-
mime_type = (
|
| 409 |
-
GeminiMimeType.application_pdf
|
| 410 |
-
if file_path.endswith(".pdf")
|
| 411 |
-
else GeminiMimeType.text_plain
|
| 412 |
-
)
|
| 413 |
-
# Use base64 string directly, not the data URI
|
| 414 |
-
with open(file_path, "rb") as f:
|
| 415 |
-
file_content = f.read()
|
| 416 |
-
import base64
|
| 417 |
-
base64_str = base64.b64encode(file_content).decode("utf-8")
|
| 418 |
-
|
| 419 |
-
return GeminiPart(
|
| 420 |
-
inlineData=GeminiInlineData(
|
| 421 |
-
mimeType=mime_type,
|
| 422 |
-
data=base64_str,
|
| 423 |
-
)
|
| 424 |
-
)
|
| 425 |
-
|
| 426 |
-
def prepare_files(
|
| 427 |
-
self, file: str, GEMINI_INPUT_FILES: list[GeminiPart] = []
|
| 428 |
-
) -> tuple[list[GeminiPart]]:
|
| 429 |
-
"""
|
| 430 |
-
Loads and formats input files for Gemini API.
|
| 431 |
-
"""
|
| 432 |
-
file_path = folder_paths.get_annotated_filepath(file)
|
| 433 |
-
input_file_content = self.create_file_part(file_path)
|
| 434 |
-
files = [input_file_content] + GEMINI_INPUT_FILES
|
| 435 |
-
return (files,)
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
NODE_CLASS_MAPPINGS = {
|
| 439 |
-
"GeminiNode": GeminiNode,
|
| 440 |
-
"GeminiInputFiles": GeminiInputFiles,
|
| 441 |
-
}
|
| 442 |
-
|
| 443 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 444 |
-
"GeminiNode": "Google Gemini",
|
| 445 |
-
"GeminiInputFiles": "Gemini Input Files",
|
| 446 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_ideogram.py
DELETED
|
@@ -1,801 +0,0 @@
|
|
| 1 |
-
from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeDict
|
| 2 |
-
from inspect import cleandoc
|
| 3 |
-
from PIL import Image
|
| 4 |
-
import numpy as np
|
| 5 |
-
import io
|
| 6 |
-
import torch
|
| 7 |
-
from comfy_api_nodes.apis import (
|
| 8 |
-
IdeogramGenerateRequest,
|
| 9 |
-
IdeogramGenerateResponse,
|
| 10 |
-
ImageRequest,
|
| 11 |
-
IdeogramV3Request,
|
| 12 |
-
IdeogramV3EditRequest,
|
| 13 |
-
)
|
| 14 |
-
|
| 15 |
-
from comfy_api_nodes.apis.client import (
|
| 16 |
-
ApiEndpoint,
|
| 17 |
-
HttpMethod,
|
| 18 |
-
SynchronousOperation,
|
| 19 |
-
)
|
| 20 |
-
|
| 21 |
-
from comfy_api_nodes.apinode_utils import (
|
| 22 |
-
download_url_to_bytesio,
|
| 23 |
-
bytesio_to_image_tensor,
|
| 24 |
-
resize_mask_to_image,
|
| 25 |
-
)
|
| 26 |
-
from server import PromptServer
|
| 27 |
-
|
| 28 |
-
V1_V1_RES_MAP = {
|
| 29 |
-
"Auto":"AUTO",
|
| 30 |
-
"512 x 1536":"RESOLUTION_512_1536",
|
| 31 |
-
"576 x 1408":"RESOLUTION_576_1408",
|
| 32 |
-
"576 x 1472":"RESOLUTION_576_1472",
|
| 33 |
-
"576 x 1536":"RESOLUTION_576_1536",
|
| 34 |
-
"640 x 1024":"RESOLUTION_640_1024",
|
| 35 |
-
"640 x 1344":"RESOLUTION_640_1344",
|
| 36 |
-
"640 x 1408":"RESOLUTION_640_1408",
|
| 37 |
-
"640 x 1472":"RESOLUTION_640_1472",
|
| 38 |
-
"640 x 1536":"RESOLUTION_640_1536",
|
| 39 |
-
"704 x 1152":"RESOLUTION_704_1152",
|
| 40 |
-
"704 x 1216":"RESOLUTION_704_1216",
|
| 41 |
-
"704 x 1280":"RESOLUTION_704_1280",
|
| 42 |
-
"704 x 1344":"RESOLUTION_704_1344",
|
| 43 |
-
"704 x 1408":"RESOLUTION_704_1408",
|
| 44 |
-
"704 x 1472":"RESOLUTION_704_1472",
|
| 45 |
-
"720 x 1280":"RESOLUTION_720_1280",
|
| 46 |
-
"736 x 1312":"RESOLUTION_736_1312",
|
| 47 |
-
"768 x 1024":"RESOLUTION_768_1024",
|
| 48 |
-
"768 x 1088":"RESOLUTION_768_1088",
|
| 49 |
-
"768 x 1152":"RESOLUTION_768_1152",
|
| 50 |
-
"768 x 1216":"RESOLUTION_768_1216",
|
| 51 |
-
"768 x 1232":"RESOLUTION_768_1232",
|
| 52 |
-
"768 x 1280":"RESOLUTION_768_1280",
|
| 53 |
-
"768 x 1344":"RESOLUTION_768_1344",
|
| 54 |
-
"832 x 960":"RESOLUTION_832_960",
|
| 55 |
-
"832 x 1024":"RESOLUTION_832_1024",
|
| 56 |
-
"832 x 1088":"RESOLUTION_832_1088",
|
| 57 |
-
"832 x 1152":"RESOLUTION_832_1152",
|
| 58 |
-
"832 x 1216":"RESOLUTION_832_1216",
|
| 59 |
-
"832 x 1248":"RESOLUTION_832_1248",
|
| 60 |
-
"864 x 1152":"RESOLUTION_864_1152",
|
| 61 |
-
"896 x 960":"RESOLUTION_896_960",
|
| 62 |
-
"896 x 1024":"RESOLUTION_896_1024",
|
| 63 |
-
"896 x 1088":"RESOLUTION_896_1088",
|
| 64 |
-
"896 x 1120":"RESOLUTION_896_1120",
|
| 65 |
-
"896 x 1152":"RESOLUTION_896_1152",
|
| 66 |
-
"960 x 832":"RESOLUTION_960_832",
|
| 67 |
-
"960 x 896":"RESOLUTION_960_896",
|
| 68 |
-
"960 x 1024":"RESOLUTION_960_1024",
|
| 69 |
-
"960 x 1088":"RESOLUTION_960_1088",
|
| 70 |
-
"1024 x 640":"RESOLUTION_1024_640",
|
| 71 |
-
"1024 x 768":"RESOLUTION_1024_768",
|
| 72 |
-
"1024 x 832":"RESOLUTION_1024_832",
|
| 73 |
-
"1024 x 896":"RESOLUTION_1024_896",
|
| 74 |
-
"1024 x 960":"RESOLUTION_1024_960",
|
| 75 |
-
"1024 x 1024":"RESOLUTION_1024_1024",
|
| 76 |
-
"1088 x 768":"RESOLUTION_1088_768",
|
| 77 |
-
"1088 x 832":"RESOLUTION_1088_832",
|
| 78 |
-
"1088 x 896":"RESOLUTION_1088_896",
|
| 79 |
-
"1088 x 960":"RESOLUTION_1088_960",
|
| 80 |
-
"1120 x 896":"RESOLUTION_1120_896",
|
| 81 |
-
"1152 x 704":"RESOLUTION_1152_704",
|
| 82 |
-
"1152 x 768":"RESOLUTION_1152_768",
|
| 83 |
-
"1152 x 832":"RESOLUTION_1152_832",
|
| 84 |
-
"1152 x 864":"RESOLUTION_1152_864",
|
| 85 |
-
"1152 x 896":"RESOLUTION_1152_896",
|
| 86 |
-
"1216 x 704":"RESOLUTION_1216_704",
|
| 87 |
-
"1216 x 768":"RESOLUTION_1216_768",
|
| 88 |
-
"1216 x 832":"RESOLUTION_1216_832",
|
| 89 |
-
"1232 x 768":"RESOLUTION_1232_768",
|
| 90 |
-
"1248 x 832":"RESOLUTION_1248_832",
|
| 91 |
-
"1280 x 704":"RESOLUTION_1280_704",
|
| 92 |
-
"1280 x 720":"RESOLUTION_1280_720",
|
| 93 |
-
"1280 x 768":"RESOLUTION_1280_768",
|
| 94 |
-
"1280 x 800":"RESOLUTION_1280_800",
|
| 95 |
-
"1312 x 736":"RESOLUTION_1312_736",
|
| 96 |
-
"1344 x 640":"RESOLUTION_1344_640",
|
| 97 |
-
"1344 x 704":"RESOLUTION_1344_704",
|
| 98 |
-
"1344 x 768":"RESOLUTION_1344_768",
|
| 99 |
-
"1408 x 576":"RESOLUTION_1408_576",
|
| 100 |
-
"1408 x 640":"RESOLUTION_1408_640",
|
| 101 |
-
"1408 x 704":"RESOLUTION_1408_704",
|
| 102 |
-
"1472 x 576":"RESOLUTION_1472_576",
|
| 103 |
-
"1472 x 640":"RESOLUTION_1472_640",
|
| 104 |
-
"1472 x 704":"RESOLUTION_1472_704",
|
| 105 |
-
"1536 x 512":"RESOLUTION_1536_512",
|
| 106 |
-
"1536 x 576":"RESOLUTION_1536_576",
|
| 107 |
-
"1536 x 640":"RESOLUTION_1536_640",
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
V1_V2_RATIO_MAP = {
|
| 111 |
-
"1:1":"ASPECT_1_1",
|
| 112 |
-
"4:3":"ASPECT_4_3",
|
| 113 |
-
"3:4":"ASPECT_3_4",
|
| 114 |
-
"16:9":"ASPECT_16_9",
|
| 115 |
-
"9:16":"ASPECT_9_16",
|
| 116 |
-
"2:1":"ASPECT_2_1",
|
| 117 |
-
"1:2":"ASPECT_1_2",
|
| 118 |
-
"3:2":"ASPECT_3_2",
|
| 119 |
-
"2:3":"ASPECT_2_3",
|
| 120 |
-
"4:5":"ASPECT_4_5",
|
| 121 |
-
"5:4":"ASPECT_5_4",
|
| 122 |
-
}
|
| 123 |
-
|
| 124 |
-
V3_RATIO_MAP = {
|
| 125 |
-
"1:3":"1x3",
|
| 126 |
-
"3:1":"3x1",
|
| 127 |
-
"1:2":"1x2",
|
| 128 |
-
"2:1":"2x1",
|
| 129 |
-
"9:16":"9x16",
|
| 130 |
-
"16:9":"16x9",
|
| 131 |
-
"10:16":"10x16",
|
| 132 |
-
"16:10":"16x10",
|
| 133 |
-
"2:3":"2x3",
|
| 134 |
-
"3:2":"3x2",
|
| 135 |
-
"3:4":"3x4",
|
| 136 |
-
"4:3":"4x3",
|
| 137 |
-
"4:5":"4x5",
|
| 138 |
-
"5:4":"5x4",
|
| 139 |
-
"1:1":"1x1",
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
V3_RESOLUTIONS= [
|
| 143 |
-
"Auto",
|
| 144 |
-
"512x1536",
|
| 145 |
-
"576x1408",
|
| 146 |
-
"576x1472",
|
| 147 |
-
"576x1536",
|
| 148 |
-
"640x1344",
|
| 149 |
-
"640x1408",
|
| 150 |
-
"640x1472",
|
| 151 |
-
"640x1536",
|
| 152 |
-
"704x1152",
|
| 153 |
-
"704x1216",
|
| 154 |
-
"704x1280",
|
| 155 |
-
"704x1344",
|
| 156 |
-
"704x1408",
|
| 157 |
-
"704x1472",
|
| 158 |
-
"736x1312",
|
| 159 |
-
"768x1088",
|
| 160 |
-
"768x1216",
|
| 161 |
-
"768x1280",
|
| 162 |
-
"768x1344",
|
| 163 |
-
"800x1280",
|
| 164 |
-
"832x960",
|
| 165 |
-
"832x1024",
|
| 166 |
-
"832x1088",
|
| 167 |
-
"832x1152",
|
| 168 |
-
"832x1216",
|
| 169 |
-
"832x1248",
|
| 170 |
-
"864x1152",
|
| 171 |
-
"896x960",
|
| 172 |
-
"896x1024",
|
| 173 |
-
"896x1088",
|
| 174 |
-
"896x1120",
|
| 175 |
-
"896x1152",
|
| 176 |
-
"960x832",
|
| 177 |
-
"960x896",
|
| 178 |
-
"960x1024",
|
| 179 |
-
"960x1088",
|
| 180 |
-
"1024x832",
|
| 181 |
-
"1024x896",
|
| 182 |
-
"1024x960",
|
| 183 |
-
"1024x1024",
|
| 184 |
-
"1088x768",
|
| 185 |
-
"1088x832",
|
| 186 |
-
"1088x896",
|
| 187 |
-
"1088x960",
|
| 188 |
-
"1120x896",
|
| 189 |
-
"1152x704",
|
| 190 |
-
"1152x832",
|
| 191 |
-
"1152x864",
|
| 192 |
-
"1152x896",
|
| 193 |
-
"1216x704",
|
| 194 |
-
"1216x768",
|
| 195 |
-
"1216x832",
|
| 196 |
-
"1248x832",
|
| 197 |
-
"1280x704",
|
| 198 |
-
"1280x768",
|
| 199 |
-
"1280x800",
|
| 200 |
-
"1312x736",
|
| 201 |
-
"1344x640",
|
| 202 |
-
"1344x704",
|
| 203 |
-
"1344x768",
|
| 204 |
-
"1408x576",
|
| 205 |
-
"1408x640",
|
| 206 |
-
"1408x704",
|
| 207 |
-
"1472x576",
|
| 208 |
-
"1472x640",
|
| 209 |
-
"1472x704",
|
| 210 |
-
"1536x512",
|
| 211 |
-
"1536x576",
|
| 212 |
-
"1536x640"
|
| 213 |
-
]
|
| 214 |
-
|
| 215 |
-
def download_and_process_images(image_urls):
|
| 216 |
-
"""Helper function to download and process multiple images from URLs"""
|
| 217 |
-
|
| 218 |
-
# Initialize list to store image tensors
|
| 219 |
-
image_tensors = []
|
| 220 |
-
|
| 221 |
-
for image_url in image_urls:
|
| 222 |
-
# Using functions from apinode_utils.py to handle downloading and processing
|
| 223 |
-
image_bytesio = download_url_to_bytesio(image_url) # Download image content to BytesIO
|
| 224 |
-
img_tensor = bytesio_to_image_tensor(image_bytesio, mode="RGB") # Convert to torch.Tensor with RGB mode
|
| 225 |
-
image_tensors.append(img_tensor)
|
| 226 |
-
|
| 227 |
-
# Stack tensors to match (N, width, height, channels)
|
| 228 |
-
if image_tensors:
|
| 229 |
-
stacked_tensors = torch.cat(image_tensors, dim=0)
|
| 230 |
-
else:
|
| 231 |
-
raise Exception("No valid images were processed")
|
| 232 |
-
|
| 233 |
-
return stacked_tensors
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
def display_image_urls_on_node(image_urls, node_id):
|
| 237 |
-
if node_id and image_urls:
|
| 238 |
-
if len(image_urls) == 1:
|
| 239 |
-
PromptServer.instance.send_progress_text(
|
| 240 |
-
f"Generated Image URL:\n{image_urls[0]}", node_id
|
| 241 |
-
)
|
| 242 |
-
else:
|
| 243 |
-
urls_text = "Generated Image URLs:\n" + "\n".join(
|
| 244 |
-
f"{i+1}. {url}" for i, url in enumerate(image_urls)
|
| 245 |
-
)
|
| 246 |
-
PromptServer.instance.send_progress_text(urls_text, node_id)
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
class IdeogramV1(ComfyNodeABC):
|
| 250 |
-
"""
|
| 251 |
-
Generates images using the Ideogram V1 model.
|
| 252 |
-
"""
|
| 253 |
-
|
| 254 |
-
def __init__(self):
|
| 255 |
-
pass
|
| 256 |
-
|
| 257 |
-
@classmethod
|
| 258 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 259 |
-
return {
|
| 260 |
-
"required": {
|
| 261 |
-
"prompt": (
|
| 262 |
-
IO.STRING,
|
| 263 |
-
{
|
| 264 |
-
"multiline": True,
|
| 265 |
-
"default": "",
|
| 266 |
-
"tooltip": "Prompt for the image generation",
|
| 267 |
-
},
|
| 268 |
-
),
|
| 269 |
-
"turbo": (
|
| 270 |
-
IO.BOOLEAN,
|
| 271 |
-
{
|
| 272 |
-
"default": False,
|
| 273 |
-
"tooltip": "Whether to use turbo mode (faster generation, potentially lower quality)",
|
| 274 |
-
}
|
| 275 |
-
),
|
| 276 |
-
},
|
| 277 |
-
"optional": {
|
| 278 |
-
"aspect_ratio": (
|
| 279 |
-
IO.COMBO,
|
| 280 |
-
{
|
| 281 |
-
"options": list(V1_V2_RATIO_MAP.keys()),
|
| 282 |
-
"default": "1:1",
|
| 283 |
-
"tooltip": "The aspect ratio for image generation.",
|
| 284 |
-
},
|
| 285 |
-
),
|
| 286 |
-
"magic_prompt_option": (
|
| 287 |
-
IO.COMBO,
|
| 288 |
-
{
|
| 289 |
-
"options": ["AUTO", "ON", "OFF"],
|
| 290 |
-
"default": "AUTO",
|
| 291 |
-
"tooltip": "Determine if MagicPrompt should be used in generation",
|
| 292 |
-
},
|
| 293 |
-
),
|
| 294 |
-
"seed": (
|
| 295 |
-
IO.INT,
|
| 296 |
-
{
|
| 297 |
-
"default": 0,
|
| 298 |
-
"min": 0,
|
| 299 |
-
"max": 2147483647,
|
| 300 |
-
"step": 1,
|
| 301 |
-
"control_after_generate": True,
|
| 302 |
-
"display": "number",
|
| 303 |
-
},
|
| 304 |
-
),
|
| 305 |
-
"negative_prompt": (
|
| 306 |
-
IO.STRING,
|
| 307 |
-
{
|
| 308 |
-
"multiline": True,
|
| 309 |
-
"default": "",
|
| 310 |
-
"tooltip": "Description of what to exclude from the image",
|
| 311 |
-
},
|
| 312 |
-
),
|
| 313 |
-
"num_images": (
|
| 314 |
-
IO.INT,
|
| 315 |
-
{"default": 1, "min": 1, "max": 8, "step": 1, "display": "number"},
|
| 316 |
-
),
|
| 317 |
-
},
|
| 318 |
-
"hidden": {
|
| 319 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 320 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 321 |
-
"unique_id": "UNIQUE_ID",
|
| 322 |
-
},
|
| 323 |
-
}
|
| 324 |
-
|
| 325 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 326 |
-
FUNCTION = "api_call"
|
| 327 |
-
CATEGORY = "api node/image/Ideogram"
|
| 328 |
-
DESCRIPTION = cleandoc(__doc__ or "")
|
| 329 |
-
API_NODE = True
|
| 330 |
-
|
| 331 |
-
def api_call(
|
| 332 |
-
self,
|
| 333 |
-
prompt,
|
| 334 |
-
turbo=False,
|
| 335 |
-
aspect_ratio="1:1",
|
| 336 |
-
magic_prompt_option="AUTO",
|
| 337 |
-
seed=0,
|
| 338 |
-
negative_prompt="",
|
| 339 |
-
num_images=1,
|
| 340 |
-
unique_id=None,
|
| 341 |
-
**kwargs,
|
| 342 |
-
):
|
| 343 |
-
# Determine the model based on turbo setting
|
| 344 |
-
aspect_ratio = V1_V2_RATIO_MAP.get(aspect_ratio, None)
|
| 345 |
-
model = "V_1_TURBO" if turbo else "V_1"
|
| 346 |
-
|
| 347 |
-
operation = SynchronousOperation(
|
| 348 |
-
endpoint=ApiEndpoint(
|
| 349 |
-
path="/proxy/ideogram/generate",
|
| 350 |
-
method=HttpMethod.POST,
|
| 351 |
-
request_model=IdeogramGenerateRequest,
|
| 352 |
-
response_model=IdeogramGenerateResponse,
|
| 353 |
-
),
|
| 354 |
-
request=IdeogramGenerateRequest(
|
| 355 |
-
image_request=ImageRequest(
|
| 356 |
-
prompt=prompt,
|
| 357 |
-
model=model,
|
| 358 |
-
num_images=num_images,
|
| 359 |
-
seed=seed,
|
| 360 |
-
aspect_ratio=aspect_ratio if aspect_ratio != "ASPECT_1_1" else None,
|
| 361 |
-
magic_prompt_option=(
|
| 362 |
-
magic_prompt_option if magic_prompt_option != "AUTO" else None
|
| 363 |
-
),
|
| 364 |
-
negative_prompt=negative_prompt if negative_prompt else None,
|
| 365 |
-
)
|
| 366 |
-
),
|
| 367 |
-
auth_kwargs=kwargs,
|
| 368 |
-
)
|
| 369 |
-
|
| 370 |
-
response = operation.execute()
|
| 371 |
-
|
| 372 |
-
if not response.data or len(response.data) == 0:
|
| 373 |
-
raise Exception("No images were generated in the response")
|
| 374 |
-
|
| 375 |
-
image_urls = [image_data.url for image_data in response.data if image_data.url]
|
| 376 |
-
|
| 377 |
-
if not image_urls:
|
| 378 |
-
raise Exception("No image URLs were generated in the response")
|
| 379 |
-
|
| 380 |
-
display_image_urls_on_node(image_urls, unique_id)
|
| 381 |
-
return (download_and_process_images(image_urls),)
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
class IdeogramV2(ComfyNodeABC):
|
| 385 |
-
"""
|
| 386 |
-
Generates images using the Ideogram V2 model.
|
| 387 |
-
"""
|
| 388 |
-
|
| 389 |
-
def __init__(self):
|
| 390 |
-
pass
|
| 391 |
-
|
| 392 |
-
@classmethod
|
| 393 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 394 |
-
return {
|
| 395 |
-
"required": {
|
| 396 |
-
"prompt": (
|
| 397 |
-
IO.STRING,
|
| 398 |
-
{
|
| 399 |
-
"multiline": True,
|
| 400 |
-
"default": "",
|
| 401 |
-
"tooltip": "Prompt for the image generation",
|
| 402 |
-
},
|
| 403 |
-
),
|
| 404 |
-
"turbo": (
|
| 405 |
-
IO.BOOLEAN,
|
| 406 |
-
{
|
| 407 |
-
"default": False,
|
| 408 |
-
"tooltip": "Whether to use turbo mode (faster generation, potentially lower quality)",
|
| 409 |
-
}
|
| 410 |
-
),
|
| 411 |
-
},
|
| 412 |
-
"optional": {
|
| 413 |
-
"aspect_ratio": (
|
| 414 |
-
IO.COMBO,
|
| 415 |
-
{
|
| 416 |
-
"options": list(V1_V2_RATIO_MAP.keys()),
|
| 417 |
-
"default": "1:1",
|
| 418 |
-
"tooltip": "The aspect ratio for image generation. Ignored if resolution is not set to AUTO.",
|
| 419 |
-
},
|
| 420 |
-
),
|
| 421 |
-
"resolution": (
|
| 422 |
-
IO.COMBO,
|
| 423 |
-
{
|
| 424 |
-
"options": list(V1_V1_RES_MAP.keys()),
|
| 425 |
-
"default": "Auto",
|
| 426 |
-
"tooltip": "The resolution for image generation. If not set to AUTO, this overrides the aspect_ratio setting.",
|
| 427 |
-
},
|
| 428 |
-
),
|
| 429 |
-
"magic_prompt_option": (
|
| 430 |
-
IO.COMBO,
|
| 431 |
-
{
|
| 432 |
-
"options": ["AUTO", "ON", "OFF"],
|
| 433 |
-
"default": "AUTO",
|
| 434 |
-
"tooltip": "Determine if MagicPrompt should be used in generation",
|
| 435 |
-
},
|
| 436 |
-
),
|
| 437 |
-
"seed": (
|
| 438 |
-
IO.INT,
|
| 439 |
-
{
|
| 440 |
-
"default": 0,
|
| 441 |
-
"min": 0,
|
| 442 |
-
"max": 2147483647,
|
| 443 |
-
"step": 1,
|
| 444 |
-
"control_after_generate": True,
|
| 445 |
-
"display": "number",
|
| 446 |
-
},
|
| 447 |
-
),
|
| 448 |
-
"style_type": (
|
| 449 |
-
IO.COMBO,
|
| 450 |
-
{
|
| 451 |
-
"options": ["AUTO", "GENERAL", "REALISTIC", "DESIGN", "RENDER_3D", "ANIME"],
|
| 452 |
-
"default": "NONE",
|
| 453 |
-
"tooltip": "Style type for generation (V2 only)",
|
| 454 |
-
},
|
| 455 |
-
),
|
| 456 |
-
"negative_prompt": (
|
| 457 |
-
IO.STRING,
|
| 458 |
-
{
|
| 459 |
-
"multiline": True,
|
| 460 |
-
"default": "",
|
| 461 |
-
"tooltip": "Description of what to exclude from the image",
|
| 462 |
-
},
|
| 463 |
-
),
|
| 464 |
-
"num_images": (
|
| 465 |
-
IO.INT,
|
| 466 |
-
{"default": 1, "min": 1, "max": 8, "step": 1, "display": "number"},
|
| 467 |
-
),
|
| 468 |
-
#"color_palette": (
|
| 469 |
-
# IO.STRING,
|
| 470 |
-
# {
|
| 471 |
-
# "multiline": False,
|
| 472 |
-
# "default": "",
|
| 473 |
-
# "tooltip": "Color palette preset name or hex colors with weights",
|
| 474 |
-
# },
|
| 475 |
-
#),
|
| 476 |
-
},
|
| 477 |
-
"hidden": {
|
| 478 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 479 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 480 |
-
"unique_id": "UNIQUE_ID",
|
| 481 |
-
},
|
| 482 |
-
}
|
| 483 |
-
|
| 484 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 485 |
-
FUNCTION = "api_call"
|
| 486 |
-
CATEGORY = "api node/image/Ideogram"
|
| 487 |
-
DESCRIPTION = cleandoc(__doc__ or "")
|
| 488 |
-
API_NODE = True
|
| 489 |
-
|
| 490 |
-
def api_call(
|
| 491 |
-
self,
|
| 492 |
-
prompt,
|
| 493 |
-
turbo=False,
|
| 494 |
-
aspect_ratio="1:1",
|
| 495 |
-
resolution="Auto",
|
| 496 |
-
magic_prompt_option="AUTO",
|
| 497 |
-
seed=0,
|
| 498 |
-
style_type="NONE",
|
| 499 |
-
negative_prompt="",
|
| 500 |
-
num_images=1,
|
| 501 |
-
color_palette="",
|
| 502 |
-
unique_id=None,
|
| 503 |
-
**kwargs,
|
| 504 |
-
):
|
| 505 |
-
aspect_ratio = V1_V2_RATIO_MAP.get(aspect_ratio, None)
|
| 506 |
-
resolution = V1_V1_RES_MAP.get(resolution, None)
|
| 507 |
-
# Determine the model based on turbo setting
|
| 508 |
-
model = "V_2_TURBO" if turbo else "V_2"
|
| 509 |
-
|
| 510 |
-
# Handle resolution vs aspect_ratio logic
|
| 511 |
-
# If resolution is not AUTO, it overrides aspect_ratio
|
| 512 |
-
final_resolution = None
|
| 513 |
-
final_aspect_ratio = None
|
| 514 |
-
|
| 515 |
-
if resolution != "AUTO":
|
| 516 |
-
final_resolution = resolution
|
| 517 |
-
else:
|
| 518 |
-
final_aspect_ratio = aspect_ratio if aspect_ratio != "ASPECT_1_1" else None
|
| 519 |
-
|
| 520 |
-
operation = SynchronousOperation(
|
| 521 |
-
endpoint=ApiEndpoint(
|
| 522 |
-
path="/proxy/ideogram/generate",
|
| 523 |
-
method=HttpMethod.POST,
|
| 524 |
-
request_model=IdeogramGenerateRequest,
|
| 525 |
-
response_model=IdeogramGenerateResponse,
|
| 526 |
-
),
|
| 527 |
-
request=IdeogramGenerateRequest(
|
| 528 |
-
image_request=ImageRequest(
|
| 529 |
-
prompt=prompt,
|
| 530 |
-
model=model,
|
| 531 |
-
num_images=num_images,
|
| 532 |
-
seed=seed,
|
| 533 |
-
aspect_ratio=final_aspect_ratio,
|
| 534 |
-
resolution=final_resolution,
|
| 535 |
-
magic_prompt_option=(
|
| 536 |
-
magic_prompt_option if magic_prompt_option != "AUTO" else None
|
| 537 |
-
),
|
| 538 |
-
style_type=style_type if style_type != "NONE" else None,
|
| 539 |
-
negative_prompt=negative_prompt if negative_prompt else None,
|
| 540 |
-
color_palette=color_palette if color_palette else None,
|
| 541 |
-
)
|
| 542 |
-
),
|
| 543 |
-
auth_kwargs=kwargs,
|
| 544 |
-
)
|
| 545 |
-
|
| 546 |
-
response = operation.execute()
|
| 547 |
-
|
| 548 |
-
if not response.data or len(response.data) == 0:
|
| 549 |
-
raise Exception("No images were generated in the response")
|
| 550 |
-
|
| 551 |
-
image_urls = [image_data.url for image_data in response.data if image_data.url]
|
| 552 |
-
|
| 553 |
-
if not image_urls:
|
| 554 |
-
raise Exception("No image URLs were generated in the response")
|
| 555 |
-
|
| 556 |
-
display_image_urls_on_node(image_urls, unique_id)
|
| 557 |
-
return (download_and_process_images(image_urls),)
|
| 558 |
-
|
| 559 |
-
class IdeogramV3(ComfyNodeABC):
|
| 560 |
-
"""
|
| 561 |
-
Generates images using the Ideogram V3 model. Supports both regular image generation from text prompts and image editing with mask.
|
| 562 |
-
"""
|
| 563 |
-
|
| 564 |
-
def __init__(self):
|
| 565 |
-
pass
|
| 566 |
-
|
| 567 |
-
@classmethod
|
| 568 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 569 |
-
return {
|
| 570 |
-
"required": {
|
| 571 |
-
"prompt": (
|
| 572 |
-
IO.STRING,
|
| 573 |
-
{
|
| 574 |
-
"multiline": True,
|
| 575 |
-
"default": "",
|
| 576 |
-
"tooltip": "Prompt for the image generation or editing",
|
| 577 |
-
},
|
| 578 |
-
),
|
| 579 |
-
},
|
| 580 |
-
"optional": {
|
| 581 |
-
"image": (
|
| 582 |
-
IO.IMAGE,
|
| 583 |
-
{
|
| 584 |
-
"default": None,
|
| 585 |
-
"tooltip": "Optional reference image for image editing.",
|
| 586 |
-
},
|
| 587 |
-
),
|
| 588 |
-
"mask": (
|
| 589 |
-
IO.MASK,
|
| 590 |
-
{
|
| 591 |
-
"default": None,
|
| 592 |
-
"tooltip": "Optional mask for inpainting (white areas will be replaced)",
|
| 593 |
-
},
|
| 594 |
-
),
|
| 595 |
-
"aspect_ratio": (
|
| 596 |
-
IO.COMBO,
|
| 597 |
-
{
|
| 598 |
-
"options": list(V3_RATIO_MAP.keys()),
|
| 599 |
-
"default": "1:1",
|
| 600 |
-
"tooltip": "The aspect ratio for image generation. Ignored if resolution is not set to Auto.",
|
| 601 |
-
},
|
| 602 |
-
),
|
| 603 |
-
"resolution": (
|
| 604 |
-
IO.COMBO,
|
| 605 |
-
{
|
| 606 |
-
"options": V3_RESOLUTIONS,
|
| 607 |
-
"default": "Auto",
|
| 608 |
-
"tooltip": "The resolution for image generation. If not set to Auto, this overrides the aspect_ratio setting.",
|
| 609 |
-
},
|
| 610 |
-
),
|
| 611 |
-
"magic_prompt_option": (
|
| 612 |
-
IO.COMBO,
|
| 613 |
-
{
|
| 614 |
-
"options": ["AUTO", "ON", "OFF"],
|
| 615 |
-
"default": "AUTO",
|
| 616 |
-
"tooltip": "Determine if MagicPrompt should be used in generation",
|
| 617 |
-
},
|
| 618 |
-
),
|
| 619 |
-
"seed": (
|
| 620 |
-
IO.INT,
|
| 621 |
-
{
|
| 622 |
-
"default": 0,
|
| 623 |
-
"min": 0,
|
| 624 |
-
"max": 2147483647,
|
| 625 |
-
"step": 1,
|
| 626 |
-
"control_after_generate": True,
|
| 627 |
-
"display": "number",
|
| 628 |
-
},
|
| 629 |
-
),
|
| 630 |
-
"num_images": (
|
| 631 |
-
IO.INT,
|
| 632 |
-
{"default": 1, "min": 1, "max": 8, "step": 1, "display": "number"},
|
| 633 |
-
),
|
| 634 |
-
"rendering_speed": (
|
| 635 |
-
IO.COMBO,
|
| 636 |
-
{
|
| 637 |
-
"options": ["BALANCED", "TURBO", "QUALITY"],
|
| 638 |
-
"default": "BALANCED",
|
| 639 |
-
"tooltip": "Controls the trade-off between generation speed and quality",
|
| 640 |
-
},
|
| 641 |
-
),
|
| 642 |
-
},
|
| 643 |
-
"hidden": {
|
| 644 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 645 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 646 |
-
"unique_id": "UNIQUE_ID",
|
| 647 |
-
},
|
| 648 |
-
}
|
| 649 |
-
|
| 650 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 651 |
-
FUNCTION = "api_call"
|
| 652 |
-
CATEGORY = "api node/image/Ideogram"
|
| 653 |
-
DESCRIPTION = cleandoc(__doc__ or "")
|
| 654 |
-
API_NODE = True
|
| 655 |
-
|
| 656 |
-
def api_call(
|
| 657 |
-
self,
|
| 658 |
-
prompt,
|
| 659 |
-
image=None,
|
| 660 |
-
mask=None,
|
| 661 |
-
resolution="Auto",
|
| 662 |
-
aspect_ratio="1:1",
|
| 663 |
-
magic_prompt_option="AUTO",
|
| 664 |
-
seed=0,
|
| 665 |
-
num_images=1,
|
| 666 |
-
rendering_speed="BALANCED",
|
| 667 |
-
unique_id=None,
|
| 668 |
-
**kwargs,
|
| 669 |
-
):
|
| 670 |
-
# Check if both image and mask are provided for editing mode
|
| 671 |
-
if image is not None and mask is not None:
|
| 672 |
-
# Edit mode
|
| 673 |
-
path = "/proxy/ideogram/ideogram-v3/edit"
|
| 674 |
-
|
| 675 |
-
# Process image and mask
|
| 676 |
-
input_tensor = image.squeeze().cpu()
|
| 677 |
-
# Resize mask to match image dimension
|
| 678 |
-
mask = resize_mask_to_image(mask, image, allow_gradient=False)
|
| 679 |
-
# Invert mask, as Ideogram API will edit black areas instead of white areas (opposite of convention).
|
| 680 |
-
mask = 1.0 - mask
|
| 681 |
-
|
| 682 |
-
# Validate mask dimensions match image
|
| 683 |
-
if mask.shape[1:] != image.shape[1:-1]:
|
| 684 |
-
raise Exception("Mask and Image must be the same size")
|
| 685 |
-
|
| 686 |
-
# Process image
|
| 687 |
-
img_np = (input_tensor.numpy() * 255).astype(np.uint8)
|
| 688 |
-
img = Image.fromarray(img_np)
|
| 689 |
-
img_byte_arr = io.BytesIO()
|
| 690 |
-
img.save(img_byte_arr, format="PNG")
|
| 691 |
-
img_byte_arr.seek(0)
|
| 692 |
-
img_binary = img_byte_arr
|
| 693 |
-
img_binary.name = "image.png"
|
| 694 |
-
|
| 695 |
-
# Process mask - white areas will be replaced
|
| 696 |
-
mask_np = (mask.squeeze().cpu().numpy() * 255).astype(np.uint8)
|
| 697 |
-
mask_img = Image.fromarray(mask_np)
|
| 698 |
-
mask_byte_arr = io.BytesIO()
|
| 699 |
-
mask_img.save(mask_byte_arr, format="PNG")
|
| 700 |
-
mask_byte_arr.seek(0)
|
| 701 |
-
mask_binary = mask_byte_arr
|
| 702 |
-
mask_binary.name = "mask.png"
|
| 703 |
-
|
| 704 |
-
# Create edit request
|
| 705 |
-
edit_request = IdeogramV3EditRequest(
|
| 706 |
-
prompt=prompt,
|
| 707 |
-
rendering_speed=rendering_speed,
|
| 708 |
-
)
|
| 709 |
-
|
| 710 |
-
# Add optional parameters
|
| 711 |
-
if magic_prompt_option != "AUTO":
|
| 712 |
-
edit_request.magic_prompt = magic_prompt_option
|
| 713 |
-
if seed != 0:
|
| 714 |
-
edit_request.seed = seed
|
| 715 |
-
if num_images > 1:
|
| 716 |
-
edit_request.num_images = num_images
|
| 717 |
-
|
| 718 |
-
# Execute the operation for edit mode
|
| 719 |
-
operation = SynchronousOperation(
|
| 720 |
-
endpoint=ApiEndpoint(
|
| 721 |
-
path=path,
|
| 722 |
-
method=HttpMethod.POST,
|
| 723 |
-
request_model=IdeogramV3EditRequest,
|
| 724 |
-
response_model=IdeogramGenerateResponse,
|
| 725 |
-
),
|
| 726 |
-
request=edit_request,
|
| 727 |
-
files={
|
| 728 |
-
"image": img_binary,
|
| 729 |
-
"mask": mask_binary,
|
| 730 |
-
},
|
| 731 |
-
content_type="multipart/form-data",
|
| 732 |
-
auth_kwargs=kwargs,
|
| 733 |
-
)
|
| 734 |
-
|
| 735 |
-
elif image is not None or mask is not None:
|
| 736 |
-
# If only one of image or mask is provided, raise an error
|
| 737 |
-
raise Exception("Ideogram V3 image editing requires both an image AND a mask")
|
| 738 |
-
else:
|
| 739 |
-
# Generation mode
|
| 740 |
-
path = "/proxy/ideogram/ideogram-v3/generate"
|
| 741 |
-
|
| 742 |
-
# Create generation request
|
| 743 |
-
gen_request = IdeogramV3Request(
|
| 744 |
-
prompt=prompt,
|
| 745 |
-
rendering_speed=rendering_speed,
|
| 746 |
-
)
|
| 747 |
-
|
| 748 |
-
# Handle resolution vs aspect ratio
|
| 749 |
-
if resolution != "Auto":
|
| 750 |
-
gen_request.resolution = resolution
|
| 751 |
-
elif aspect_ratio != "1:1":
|
| 752 |
-
v3_aspect = V3_RATIO_MAP.get(aspect_ratio)
|
| 753 |
-
if v3_aspect:
|
| 754 |
-
gen_request.aspect_ratio = v3_aspect
|
| 755 |
-
|
| 756 |
-
# Add optional parameters
|
| 757 |
-
if magic_prompt_option != "AUTO":
|
| 758 |
-
gen_request.magic_prompt = magic_prompt_option
|
| 759 |
-
if seed != 0:
|
| 760 |
-
gen_request.seed = seed
|
| 761 |
-
if num_images > 1:
|
| 762 |
-
gen_request.num_images = num_images
|
| 763 |
-
|
| 764 |
-
# Execute the operation for generation mode
|
| 765 |
-
operation = SynchronousOperation(
|
| 766 |
-
endpoint=ApiEndpoint(
|
| 767 |
-
path=path,
|
| 768 |
-
method=HttpMethod.POST,
|
| 769 |
-
request_model=IdeogramV3Request,
|
| 770 |
-
response_model=IdeogramGenerateResponse,
|
| 771 |
-
),
|
| 772 |
-
request=gen_request,
|
| 773 |
-
auth_kwargs=kwargs,
|
| 774 |
-
)
|
| 775 |
-
|
| 776 |
-
# Execute the operation and process response
|
| 777 |
-
response = operation.execute()
|
| 778 |
-
|
| 779 |
-
if not response.data or len(response.data) == 0:
|
| 780 |
-
raise Exception("No images were generated in the response")
|
| 781 |
-
|
| 782 |
-
image_urls = [image_data.url for image_data in response.data if image_data.url]
|
| 783 |
-
|
| 784 |
-
if not image_urls:
|
| 785 |
-
raise Exception("No image URLs were generated in the response")
|
| 786 |
-
|
| 787 |
-
display_image_urls_on_node(image_urls, unique_id)
|
| 788 |
-
return (download_and_process_images(image_urls),)
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
NODE_CLASS_MAPPINGS = {
|
| 792 |
-
"IdeogramV1": IdeogramV1,
|
| 793 |
-
"IdeogramV2": IdeogramV2,
|
| 794 |
-
"IdeogramV3": IdeogramV3,
|
| 795 |
-
}
|
| 796 |
-
|
| 797 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 798 |
-
"IdeogramV1": "Ideogram V1",
|
| 799 |
-
"IdeogramV2": "Ideogram V2",
|
| 800 |
-
"IdeogramV3": "Ideogram V3",
|
| 801 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_kling.py
DELETED
|
@@ -1,1760 +0,0 @@
|
|
| 1 |
-
"""Kling API Nodes
|
| 2 |
-
|
| 3 |
-
For source of truth on the allowed permutations of request fields, please reference:
|
| 4 |
-
- [Compatibility Table](https://app.klingai.com/global/dev/document-api/apiReference/model/skillsMap)
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
from typing import Optional, TypeVar, Any
|
| 9 |
-
from collections.abc import Callable
|
| 10 |
-
import math
|
| 11 |
-
import logging
|
| 12 |
-
|
| 13 |
-
import torch
|
| 14 |
-
|
| 15 |
-
from comfy_api_nodes.apis import (
|
| 16 |
-
KlingTaskStatus,
|
| 17 |
-
KlingCameraControl,
|
| 18 |
-
KlingCameraConfig,
|
| 19 |
-
KlingCameraControlType,
|
| 20 |
-
KlingVideoGenDuration,
|
| 21 |
-
KlingVideoGenMode,
|
| 22 |
-
KlingVideoGenAspectRatio,
|
| 23 |
-
KlingVideoGenModelName,
|
| 24 |
-
KlingText2VideoRequest,
|
| 25 |
-
KlingText2VideoResponse,
|
| 26 |
-
KlingImage2VideoRequest,
|
| 27 |
-
KlingImage2VideoResponse,
|
| 28 |
-
KlingVideoExtendRequest,
|
| 29 |
-
KlingVideoExtendResponse,
|
| 30 |
-
KlingLipSyncVoiceLanguage,
|
| 31 |
-
KlingLipSyncInputObject,
|
| 32 |
-
KlingLipSyncRequest,
|
| 33 |
-
KlingLipSyncResponse,
|
| 34 |
-
KlingVirtualTryOnModelName,
|
| 35 |
-
KlingVirtualTryOnRequest,
|
| 36 |
-
KlingVirtualTryOnResponse,
|
| 37 |
-
KlingVideoResult,
|
| 38 |
-
KlingImageResult,
|
| 39 |
-
KlingImageGenerationsRequest,
|
| 40 |
-
KlingImageGenerationsResponse,
|
| 41 |
-
KlingImageGenImageReferenceType,
|
| 42 |
-
KlingImageGenModelName,
|
| 43 |
-
KlingImageGenAspectRatio,
|
| 44 |
-
KlingVideoEffectsRequest,
|
| 45 |
-
KlingVideoEffectsResponse,
|
| 46 |
-
KlingDualCharacterEffectsScene,
|
| 47 |
-
KlingSingleImageEffectsScene,
|
| 48 |
-
KlingDualCharacterEffectInput,
|
| 49 |
-
KlingSingleImageEffectInput,
|
| 50 |
-
KlingCharacterEffectModelName,
|
| 51 |
-
KlingSingleImageEffectModelName,
|
| 52 |
-
)
|
| 53 |
-
from comfy_api_nodes.apis.client import (
|
| 54 |
-
ApiEndpoint,
|
| 55 |
-
HttpMethod,
|
| 56 |
-
SynchronousOperation,
|
| 57 |
-
PollingOperation,
|
| 58 |
-
EmptyRequest,
|
| 59 |
-
)
|
| 60 |
-
from comfy_api_nodes.apinode_utils import (
|
| 61 |
-
tensor_to_base64_string,
|
| 62 |
-
download_url_to_video_output,
|
| 63 |
-
upload_video_to_comfyapi,
|
| 64 |
-
upload_audio_to_comfyapi,
|
| 65 |
-
download_url_to_image_tensor,
|
| 66 |
-
)
|
| 67 |
-
from comfy_api_nodes.mapper_utils import model_field_to_node_input
|
| 68 |
-
from comfy_api_nodes.util.validation_utils import (
|
| 69 |
-
validate_image_dimensions,
|
| 70 |
-
validate_image_aspect_ratio,
|
| 71 |
-
validate_video_dimensions,
|
| 72 |
-
validate_video_duration,
|
| 73 |
-
)
|
| 74 |
-
from comfy_api.input.basic_types import AudioInput
|
| 75 |
-
from comfy_api.input.video_types import VideoInput
|
| 76 |
-
from comfy_api.input_impl import VideoFromFile
|
| 77 |
-
from comfy.comfy_types.node_typing import IO, InputTypeOptions, ComfyNodeABC
|
| 78 |
-
|
| 79 |
-
KLING_API_VERSION = "v1"
|
| 80 |
-
PATH_TEXT_TO_VIDEO = f"/proxy/kling/{KLING_API_VERSION}/videos/text2video"
|
| 81 |
-
PATH_IMAGE_TO_VIDEO = f"/proxy/kling/{KLING_API_VERSION}/videos/image2video"
|
| 82 |
-
PATH_VIDEO_EXTEND = f"/proxy/kling/{KLING_API_VERSION}/videos/video-extend"
|
| 83 |
-
PATH_LIP_SYNC = f"/proxy/kling/{KLING_API_VERSION}/videos/lip-sync"
|
| 84 |
-
PATH_VIDEO_EFFECTS = f"/proxy/kling/{KLING_API_VERSION}/videos/effects"
|
| 85 |
-
PATH_CHARACTER_IMAGE = f"/proxy/kling/{KLING_API_VERSION}/images/generations"
|
| 86 |
-
PATH_VIRTUAL_TRY_ON = f"/proxy/kling/{KLING_API_VERSION}/images/kolors-virtual-try-on"
|
| 87 |
-
PATH_IMAGE_GENERATIONS = f"/proxy/kling/{KLING_API_VERSION}/images/generations"
|
| 88 |
-
|
| 89 |
-
MAX_PROMPT_LENGTH_T2V = 2500
|
| 90 |
-
MAX_PROMPT_LENGTH_I2V = 500
|
| 91 |
-
MAX_PROMPT_LENGTH_IMAGE_GEN = 500
|
| 92 |
-
MAX_NEGATIVE_PROMPT_LENGTH_IMAGE_GEN = 200
|
| 93 |
-
MAX_PROMPT_LENGTH_LIP_SYNC = 120
|
| 94 |
-
|
| 95 |
-
AVERAGE_DURATION_T2V = 319
|
| 96 |
-
AVERAGE_DURATION_I2V = 164
|
| 97 |
-
AVERAGE_DURATION_LIP_SYNC = 455
|
| 98 |
-
AVERAGE_DURATION_VIRTUAL_TRY_ON = 19
|
| 99 |
-
AVERAGE_DURATION_IMAGE_GEN = 32
|
| 100 |
-
AVERAGE_DURATION_VIDEO_EFFECTS = 320
|
| 101 |
-
AVERAGE_DURATION_VIDEO_EXTEND = 320
|
| 102 |
-
|
| 103 |
-
R = TypeVar("R")
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
class KlingApiError(Exception):
|
| 107 |
-
"""Base exception for Kling API errors."""
|
| 108 |
-
|
| 109 |
-
pass
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def poll_until_finished(
|
| 113 |
-
auth_kwargs: dict[str, str],
|
| 114 |
-
api_endpoint: ApiEndpoint[Any, R],
|
| 115 |
-
result_url_extractor: Optional[Callable[[R], str]] = None,
|
| 116 |
-
estimated_duration: Optional[int] = None,
|
| 117 |
-
node_id: Optional[str] = None,
|
| 118 |
-
) -> R:
|
| 119 |
-
"""Polls the Kling API endpoint until the task reaches a terminal state, then returns the response."""
|
| 120 |
-
return PollingOperation(
|
| 121 |
-
poll_endpoint=api_endpoint,
|
| 122 |
-
completed_statuses=[
|
| 123 |
-
KlingTaskStatus.succeed.value,
|
| 124 |
-
],
|
| 125 |
-
failed_statuses=[KlingTaskStatus.failed.value],
|
| 126 |
-
status_extractor=lambda response: (
|
| 127 |
-
response.data.task_status.value
|
| 128 |
-
if response.data and response.data.task_status
|
| 129 |
-
else None
|
| 130 |
-
),
|
| 131 |
-
auth_kwargs=auth_kwargs,
|
| 132 |
-
result_url_extractor=result_url_extractor,
|
| 133 |
-
estimated_duration=estimated_duration,
|
| 134 |
-
node_id=node_id,
|
| 135 |
-
poll_interval=16.0,
|
| 136 |
-
max_poll_attempts=256,
|
| 137 |
-
).execute()
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def is_valid_camera_control_configs(configs: list[float]) -> bool:
|
| 141 |
-
"""Verifies that at least one camera control configuration is non-zero."""
|
| 142 |
-
return any(not math.isclose(value, 0.0) for value in configs)
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
def is_valid_prompt(prompt: str) -> bool:
|
| 146 |
-
"""Verifies that the prompt is not empty."""
|
| 147 |
-
return bool(prompt)
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def is_valid_task_creation_response(response: KlingText2VideoResponse) -> bool:
|
| 151 |
-
"""Verifies that the initial response contains a task ID."""
|
| 152 |
-
return bool(response.data.task_id)
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def is_valid_video_response(response: KlingText2VideoResponse) -> bool:
|
| 156 |
-
"""Verifies that the response contains a task result with at least one video."""
|
| 157 |
-
return (
|
| 158 |
-
response.data is not None
|
| 159 |
-
and response.data.task_result is not None
|
| 160 |
-
and response.data.task_result.videos is not None
|
| 161 |
-
and len(response.data.task_result.videos) > 0
|
| 162 |
-
)
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def is_valid_image_response(response: KlingVirtualTryOnResponse) -> bool:
|
| 166 |
-
"""Verifies that the response contains a task result with at least one image."""
|
| 167 |
-
return (
|
| 168 |
-
response.data is not None
|
| 169 |
-
and response.data.task_result is not None
|
| 170 |
-
and response.data.task_result.images is not None
|
| 171 |
-
and len(response.data.task_result.images) > 0
|
| 172 |
-
)
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
def validate_prompts(prompt: str, negative_prompt: str, max_length: int) -> bool:
|
| 176 |
-
"""Verifies that the positive prompt is not empty and that neither promt is too long."""
|
| 177 |
-
if not prompt:
|
| 178 |
-
raise ValueError("Positive prompt is empty")
|
| 179 |
-
if len(prompt) > max_length:
|
| 180 |
-
raise ValueError(f"Positive prompt is too long: {len(prompt)} characters")
|
| 181 |
-
if negative_prompt and len(negative_prompt) > max_length:
|
| 182 |
-
raise ValueError(
|
| 183 |
-
f"Negative prompt is too long: {len(negative_prompt)} characters"
|
| 184 |
-
)
|
| 185 |
-
return True
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
def validate_task_creation_response(response) -> None:
|
| 189 |
-
"""Validates that the Kling task creation request was successful."""
|
| 190 |
-
if not is_valid_task_creation_response(response):
|
| 191 |
-
error_msg = f"Kling initial request failed. Code: {response.code}, Message: {response.message}, Data: {response.data}"
|
| 192 |
-
logging.error(error_msg)
|
| 193 |
-
raise KlingApiError(error_msg)
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
def validate_video_result_response(response) -> None:
|
| 197 |
-
"""Validates that the Kling task result contains a video."""
|
| 198 |
-
if not is_valid_video_response(response):
|
| 199 |
-
error_msg = f"Kling task {response.data.task_id} succeeded but no video data found in response."
|
| 200 |
-
logging.error(f"Error: {error_msg}.\nResponse: {response}")
|
| 201 |
-
raise KlingApiError(error_msg)
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
def validate_image_result_response(response) -> None:
|
| 205 |
-
"""Validates that the Kling task result contains an image."""
|
| 206 |
-
if not is_valid_image_response(response):
|
| 207 |
-
error_msg = f"Kling task {response.data.task_id} succeeded but no image data found in response."
|
| 208 |
-
logging.error(f"Error: {error_msg}.\nResponse: {response}")
|
| 209 |
-
raise KlingApiError(error_msg)
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
def validate_input_image(image: torch.Tensor) -> None:
|
| 213 |
-
"""
|
| 214 |
-
Validates the input image adheres to the expectations of the Kling API:
|
| 215 |
-
- The image resolution should not be less than 300*300px
|
| 216 |
-
- The aspect ratio of the image should be between 1:2.5 ~ 2.5:1
|
| 217 |
-
|
| 218 |
-
See: https://app.klingai.com/global/dev/document-api/apiReference/model/imageToVideo
|
| 219 |
-
"""
|
| 220 |
-
validate_image_dimensions(image, min_width=300, min_height=300)
|
| 221 |
-
validate_image_aspect_ratio(image, min_aspect_ratio=1 / 2.5, max_aspect_ratio=2.5)
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
def get_camera_control_input_config(
|
| 225 |
-
tooltip: str, default: float = 0.0
|
| 226 |
-
) -> tuple[IO, InputTypeOptions]:
|
| 227 |
-
"""Returns common InputTypeOptions for Kling camera control configurations."""
|
| 228 |
-
input_config = {
|
| 229 |
-
"default": default,
|
| 230 |
-
"min": -10.0,
|
| 231 |
-
"max": 10.0,
|
| 232 |
-
"step": 0.25,
|
| 233 |
-
"display": "slider",
|
| 234 |
-
"tooltip": tooltip,
|
| 235 |
-
}
|
| 236 |
-
return IO.FLOAT, input_config
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
def get_video_from_response(response) -> KlingVideoResult:
|
| 240 |
-
"""Returns the first video object from the Kling video generation task result.
|
| 241 |
-
Will raise an error if the response is not valid.
|
| 242 |
-
"""
|
| 243 |
-
video = response.data.task_result.videos[0]
|
| 244 |
-
logging.info(
|
| 245 |
-
"Kling task %s succeeded. Video URL: %s", response.data.task_id, video.url
|
| 246 |
-
)
|
| 247 |
-
return video
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
def get_video_url_from_response(response) -> Optional[str]:
|
| 251 |
-
"""Returns the first video url from the Kling video generation task result.
|
| 252 |
-
Will not raise an error if the response is not valid.
|
| 253 |
-
"""
|
| 254 |
-
if response and is_valid_video_response(response):
|
| 255 |
-
return str(get_video_from_response(response).url)
|
| 256 |
-
else:
|
| 257 |
-
return None
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
def get_images_from_response(response) -> list[KlingImageResult]:
|
| 261 |
-
"""Returns the list of image objects from the Kling image generation task result.
|
| 262 |
-
Will raise an error if the response is not valid.
|
| 263 |
-
"""
|
| 264 |
-
images = response.data.task_result.images
|
| 265 |
-
logging.info("Kling task %s succeeded. Images: %s", response.data.task_id, images)
|
| 266 |
-
return images
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
def get_images_urls_from_response(response) -> Optional[str]:
|
| 270 |
-
"""Returns the list of image urls from the Kling image generation task result.
|
| 271 |
-
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.
|
| 272 |
-
"""
|
| 273 |
-
if response and is_valid_image_response(response):
|
| 274 |
-
images = get_images_from_response(response)
|
| 275 |
-
image_urls = [str(image.url) for image in images]
|
| 276 |
-
return "\n".join(image_urls)
|
| 277 |
-
else:
|
| 278 |
-
return None
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
def video_result_to_node_output(
|
| 282 |
-
video: KlingVideoResult,
|
| 283 |
-
) -> tuple[VideoFromFile, str, str]:
|
| 284 |
-
"""Converts a KlingVideoResult to a tuple of (VideoFromFile, str, str) to be used as a ComfyUI node output."""
|
| 285 |
-
return (
|
| 286 |
-
download_url_to_video_output(video.url),
|
| 287 |
-
str(video.id),
|
| 288 |
-
str(video.duration),
|
| 289 |
-
)
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
def image_result_to_node_output(
|
| 293 |
-
images: list[KlingImageResult],
|
| 294 |
-
) -> torch.Tensor:
|
| 295 |
-
"""
|
| 296 |
-
Converts a KlingImageResult to a tuple containing a [B, H, W, C] tensor.
|
| 297 |
-
If multiple images are returned, they will be stacked along the batch dimension.
|
| 298 |
-
"""
|
| 299 |
-
if len(images) == 1:
|
| 300 |
-
return download_url_to_image_tensor(images[0].url)
|
| 301 |
-
else:
|
| 302 |
-
return torch.cat([download_url_to_image_tensor(image.url) for image in images])
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
class KlingNodeBase(ComfyNodeABC):
|
| 306 |
-
"""Base class for Kling nodes."""
|
| 307 |
-
|
| 308 |
-
FUNCTION = "api_call"
|
| 309 |
-
CATEGORY = "api node/video/Kling"
|
| 310 |
-
API_NODE = True
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
class KlingCameraControls(KlingNodeBase):
|
| 314 |
-
"""Kling Camera Controls Node"""
|
| 315 |
-
|
| 316 |
-
@classmethod
|
| 317 |
-
def INPUT_TYPES(cls):
|
| 318 |
-
return {
|
| 319 |
-
"required": {
|
| 320 |
-
"camera_control_type": model_field_to_node_input(
|
| 321 |
-
IO.COMBO,
|
| 322 |
-
KlingCameraControl,
|
| 323 |
-
"type",
|
| 324 |
-
enum_type=KlingCameraControlType,
|
| 325 |
-
),
|
| 326 |
-
"horizontal_movement": get_camera_control_input_config(
|
| 327 |
-
"Controls camera's movement along horizontal axis (x-axis). Negative indicates left, positive indicates right"
|
| 328 |
-
),
|
| 329 |
-
"vertical_movement": get_camera_control_input_config(
|
| 330 |
-
"Controls camera's movement along vertical axis (y-axis). Negative indicates downward, positive indicates upward."
|
| 331 |
-
),
|
| 332 |
-
"pan": get_camera_control_input_config(
|
| 333 |
-
"Controls camera's rotation in vertical plane (x-axis). Negative indicates downward rotation, positive indicates upward rotation.",
|
| 334 |
-
default=0.5,
|
| 335 |
-
),
|
| 336 |
-
"tilt": get_camera_control_input_config(
|
| 337 |
-
"Controls camera's rotation in horizontal plane (y-axis). Negative indicates left rotation, positive indicates right rotation.",
|
| 338 |
-
),
|
| 339 |
-
"roll": get_camera_control_input_config(
|
| 340 |
-
"Controls camera's rolling amount (z-axis). Negative indicates counterclockwise, positive indicates clockwise.",
|
| 341 |
-
),
|
| 342 |
-
"zoom": get_camera_control_input_config(
|
| 343 |
-
"Controls change in camera's focal length. Negative indicates narrower field of view, positive indicates wider field of view.",
|
| 344 |
-
),
|
| 345 |
-
}
|
| 346 |
-
}
|
| 347 |
-
|
| 348 |
-
DESCRIPTION = "Allows specifying configuration options for Kling Camera Controls and motion control effects."
|
| 349 |
-
RETURN_TYPES = ("CAMERA_CONTROL",)
|
| 350 |
-
RETURN_NAMES = ("camera_control",)
|
| 351 |
-
FUNCTION = "main"
|
| 352 |
-
API_NODE = False # This is just a helper node, it doesn't make an API call
|
| 353 |
-
|
| 354 |
-
@classmethod
|
| 355 |
-
def VALIDATE_INPUTS(
|
| 356 |
-
cls,
|
| 357 |
-
horizontal_movement: float,
|
| 358 |
-
vertical_movement: float,
|
| 359 |
-
pan: float,
|
| 360 |
-
tilt: float,
|
| 361 |
-
roll: float,
|
| 362 |
-
zoom: float,
|
| 363 |
-
) -> bool | str:
|
| 364 |
-
if not is_valid_camera_control_configs(
|
| 365 |
-
[
|
| 366 |
-
horizontal_movement,
|
| 367 |
-
vertical_movement,
|
| 368 |
-
pan,
|
| 369 |
-
tilt,
|
| 370 |
-
roll,
|
| 371 |
-
zoom,
|
| 372 |
-
]
|
| 373 |
-
):
|
| 374 |
-
return "Invalid camera control configs: at least one of the values must be non-zero"
|
| 375 |
-
return True
|
| 376 |
-
|
| 377 |
-
def main(
|
| 378 |
-
self,
|
| 379 |
-
camera_control_type: str,
|
| 380 |
-
horizontal_movement: float,
|
| 381 |
-
vertical_movement: float,
|
| 382 |
-
pan: float,
|
| 383 |
-
tilt: float,
|
| 384 |
-
roll: float,
|
| 385 |
-
zoom: float,
|
| 386 |
-
) -> tuple[KlingCameraControl]:
|
| 387 |
-
return (
|
| 388 |
-
KlingCameraControl(
|
| 389 |
-
type=KlingCameraControlType(camera_control_type),
|
| 390 |
-
config=KlingCameraConfig(
|
| 391 |
-
horizontal=horizontal_movement,
|
| 392 |
-
vertical=vertical_movement,
|
| 393 |
-
pan=pan,
|
| 394 |
-
roll=roll,
|
| 395 |
-
tilt=tilt,
|
| 396 |
-
zoom=zoom,
|
| 397 |
-
),
|
| 398 |
-
),
|
| 399 |
-
)
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
class KlingTextToVideoNode(KlingNodeBase):
|
| 403 |
-
"""Kling Text to Video Node"""
|
| 404 |
-
|
| 405 |
-
@staticmethod
|
| 406 |
-
def get_mode_string_mapping() -> dict[str, tuple[str, str, str]]:
|
| 407 |
-
"""
|
| 408 |
-
Returns a mapping of mode strings to their corresponding (mode, duration, model_name) tuples.
|
| 409 |
-
Only includes config combos that support the `image_tail` request field.
|
| 410 |
-
|
| 411 |
-
See: [Kling API Docs Capability Map](https://app.klingai.com/global/dev/document-api/apiReference/model/skillsMap)
|
| 412 |
-
"""
|
| 413 |
-
return {
|
| 414 |
-
"standard mode / 5s duration / kling-v1": ("std", "5", "kling-v1"),
|
| 415 |
-
"standard mode / 10s duration / kling-v1": ("std", "10", "kling-v1"),
|
| 416 |
-
"pro mode / 5s duration / kling-v1": ("pro", "5", "kling-v1"),
|
| 417 |
-
"pro mode / 10s duration / kling-v1": ("pro", "10", "kling-v1"),
|
| 418 |
-
"standard mode / 5s duration / kling-v1-6": ("std", "5", "kling-v1-6"),
|
| 419 |
-
"standard mode / 10s duration / kling-v1-6": ("std", "10", "kling-v1-6"),
|
| 420 |
-
"pro mode / 5s duration / kling-v2-master": ("pro", "5", "kling-v2-master"),
|
| 421 |
-
"pro mode / 10s duration / kling-v2-master": ("pro", "10", "kling-v2-master"),
|
| 422 |
-
"standard mode / 5s duration / kling-v2-master": ("std", "5", "kling-v2-master"),
|
| 423 |
-
"standard mode / 10s duration / kling-v2-master": ("std", "10", "kling-v2-master"),
|
| 424 |
-
}
|
| 425 |
-
|
| 426 |
-
@classmethod
|
| 427 |
-
def INPUT_TYPES(s):
|
| 428 |
-
modes = list(KlingTextToVideoNode.get_mode_string_mapping().keys())
|
| 429 |
-
return {
|
| 430 |
-
"required": {
|
| 431 |
-
"prompt": model_field_to_node_input(
|
| 432 |
-
IO.STRING, KlingText2VideoRequest, "prompt", multiline=True
|
| 433 |
-
),
|
| 434 |
-
"negative_prompt": model_field_to_node_input(
|
| 435 |
-
IO.STRING, KlingText2VideoRequest, "negative_prompt", multiline=True
|
| 436 |
-
),
|
| 437 |
-
"cfg_scale": model_field_to_node_input(
|
| 438 |
-
IO.FLOAT,
|
| 439 |
-
KlingText2VideoRequest,
|
| 440 |
-
"cfg_scale",
|
| 441 |
-
default=1.0,
|
| 442 |
-
min=0.0,
|
| 443 |
-
max=1.0,
|
| 444 |
-
),
|
| 445 |
-
"aspect_ratio": model_field_to_node_input(
|
| 446 |
-
IO.COMBO,
|
| 447 |
-
KlingText2VideoRequest,
|
| 448 |
-
"aspect_ratio",
|
| 449 |
-
enum_type=KlingVideoGenAspectRatio,
|
| 450 |
-
),
|
| 451 |
-
"mode": (
|
| 452 |
-
modes,
|
| 453 |
-
{
|
| 454 |
-
"default": modes[4],
|
| 455 |
-
"tooltip": "The configuration to use for the video generation following the format: mode / duration / model_name.",
|
| 456 |
-
},
|
| 457 |
-
),
|
| 458 |
-
},
|
| 459 |
-
"hidden": {
|
| 460 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 461 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 462 |
-
"unique_id": "UNIQUE_ID",
|
| 463 |
-
},
|
| 464 |
-
}
|
| 465 |
-
|
| 466 |
-
RETURN_TYPES = ("VIDEO", "STRING", "STRING")
|
| 467 |
-
RETURN_NAMES = ("VIDEO", "video_id", "duration")
|
| 468 |
-
DESCRIPTION = "Kling Text to Video Node"
|
| 469 |
-
|
| 470 |
-
def get_response(
|
| 471 |
-
self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
|
| 472 |
-
) -> KlingText2VideoResponse:
|
| 473 |
-
return poll_until_finished(
|
| 474 |
-
auth_kwargs,
|
| 475 |
-
ApiEndpoint(
|
| 476 |
-
path=f"{PATH_TEXT_TO_VIDEO}/{task_id}",
|
| 477 |
-
method=HttpMethod.GET,
|
| 478 |
-
request_model=EmptyRequest,
|
| 479 |
-
response_model=KlingText2VideoResponse,
|
| 480 |
-
),
|
| 481 |
-
result_url_extractor=get_video_url_from_response,
|
| 482 |
-
estimated_duration=AVERAGE_DURATION_T2V,
|
| 483 |
-
node_id=node_id,
|
| 484 |
-
)
|
| 485 |
-
|
| 486 |
-
def api_call(
|
| 487 |
-
self,
|
| 488 |
-
prompt: str,
|
| 489 |
-
negative_prompt: str,
|
| 490 |
-
cfg_scale: float,
|
| 491 |
-
mode: str,
|
| 492 |
-
aspect_ratio: str,
|
| 493 |
-
camera_control: Optional[KlingCameraControl] = None,
|
| 494 |
-
model_name: Optional[str] = None,
|
| 495 |
-
duration: Optional[str] = None,
|
| 496 |
-
unique_id: Optional[str] = None,
|
| 497 |
-
**kwargs,
|
| 498 |
-
) -> tuple[VideoFromFile, str, str]:
|
| 499 |
-
validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V)
|
| 500 |
-
if model_name is None:
|
| 501 |
-
mode, duration, model_name = self.get_mode_string_mapping()[mode]
|
| 502 |
-
initial_operation = SynchronousOperation(
|
| 503 |
-
endpoint=ApiEndpoint(
|
| 504 |
-
path=PATH_TEXT_TO_VIDEO,
|
| 505 |
-
method=HttpMethod.POST,
|
| 506 |
-
request_model=KlingText2VideoRequest,
|
| 507 |
-
response_model=KlingText2VideoResponse,
|
| 508 |
-
),
|
| 509 |
-
request=KlingText2VideoRequest(
|
| 510 |
-
prompt=prompt if prompt else None,
|
| 511 |
-
negative_prompt=negative_prompt if negative_prompt else None,
|
| 512 |
-
duration=KlingVideoGenDuration(duration),
|
| 513 |
-
mode=KlingVideoGenMode(mode),
|
| 514 |
-
model_name=KlingVideoGenModelName(model_name),
|
| 515 |
-
cfg_scale=cfg_scale,
|
| 516 |
-
aspect_ratio=KlingVideoGenAspectRatio(aspect_ratio),
|
| 517 |
-
camera_control=camera_control,
|
| 518 |
-
),
|
| 519 |
-
auth_kwargs=kwargs,
|
| 520 |
-
)
|
| 521 |
-
|
| 522 |
-
task_creation_response = initial_operation.execute()
|
| 523 |
-
validate_task_creation_response(task_creation_response)
|
| 524 |
-
|
| 525 |
-
task_id = task_creation_response.data.task_id
|
| 526 |
-
final_response = self.get_response(
|
| 527 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 528 |
-
)
|
| 529 |
-
validate_video_result_response(final_response)
|
| 530 |
-
|
| 531 |
-
video = get_video_from_response(final_response)
|
| 532 |
-
return video_result_to_node_output(video)
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
class KlingCameraControlT2VNode(KlingTextToVideoNode):
|
| 536 |
-
"""
|
| 537 |
-
Kling Text to Video Camera Control Node. This node is a text to video node, but it supports controlling the camera.
|
| 538 |
-
Duration, mode, and model_name request fields are hard-coded because camera control is only supported in pro mode with the kling-v1-5 model at 5s duration as of 2025-05-02.
|
| 539 |
-
"""
|
| 540 |
-
|
| 541 |
-
@classmethod
|
| 542 |
-
def INPUT_TYPES(s):
|
| 543 |
-
return {
|
| 544 |
-
"required": {
|
| 545 |
-
"prompt": model_field_to_node_input(
|
| 546 |
-
IO.STRING, KlingText2VideoRequest, "prompt", multiline=True
|
| 547 |
-
),
|
| 548 |
-
"negative_prompt": model_field_to_node_input(
|
| 549 |
-
IO.STRING,
|
| 550 |
-
KlingText2VideoRequest,
|
| 551 |
-
"negative_prompt",
|
| 552 |
-
multiline=True,
|
| 553 |
-
),
|
| 554 |
-
"cfg_scale": model_field_to_node_input(
|
| 555 |
-
IO.FLOAT,
|
| 556 |
-
KlingText2VideoRequest,
|
| 557 |
-
"cfg_scale",
|
| 558 |
-
default=0.75,
|
| 559 |
-
min=0.0,
|
| 560 |
-
max=1.0,
|
| 561 |
-
),
|
| 562 |
-
"aspect_ratio": model_field_to_node_input(
|
| 563 |
-
IO.COMBO,
|
| 564 |
-
KlingText2VideoRequest,
|
| 565 |
-
"aspect_ratio",
|
| 566 |
-
enum_type=KlingVideoGenAspectRatio,
|
| 567 |
-
),
|
| 568 |
-
"camera_control": (
|
| 569 |
-
"CAMERA_CONTROL",
|
| 570 |
-
{
|
| 571 |
-
"tooltip": "Can be created using the Kling Camera Controls node. Controls the camera movement and motion during the video generation.",
|
| 572 |
-
},
|
| 573 |
-
),
|
| 574 |
-
},
|
| 575 |
-
"hidden": {
|
| 576 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 577 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 578 |
-
"unique_id": "UNIQUE_ID",
|
| 579 |
-
},
|
| 580 |
-
}
|
| 581 |
-
|
| 582 |
-
DESCRIPTION = "Transform text into cinematic videos with professional camera movements that simulate real-world cinematography. Control virtual camera actions including zoom, rotation, pan, tilt, and first-person view, while maintaining focus on your original text."
|
| 583 |
-
|
| 584 |
-
def api_call(
|
| 585 |
-
self,
|
| 586 |
-
prompt: str,
|
| 587 |
-
negative_prompt: str,
|
| 588 |
-
cfg_scale: float,
|
| 589 |
-
aspect_ratio: str,
|
| 590 |
-
camera_control: Optional[KlingCameraControl] = None,
|
| 591 |
-
unique_id: Optional[str] = None,
|
| 592 |
-
**kwargs,
|
| 593 |
-
):
|
| 594 |
-
return super().api_call(
|
| 595 |
-
model_name=KlingVideoGenModelName.kling_v1,
|
| 596 |
-
cfg_scale=cfg_scale,
|
| 597 |
-
mode=KlingVideoGenMode.std,
|
| 598 |
-
aspect_ratio=KlingVideoGenAspectRatio(aspect_ratio),
|
| 599 |
-
duration=KlingVideoGenDuration.field_5,
|
| 600 |
-
prompt=prompt,
|
| 601 |
-
negative_prompt=negative_prompt,
|
| 602 |
-
camera_control=camera_control,
|
| 603 |
-
**kwargs,
|
| 604 |
-
)
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
class KlingImage2VideoNode(KlingNodeBase):
|
| 608 |
-
"""Kling Image to Video Node"""
|
| 609 |
-
|
| 610 |
-
@classmethod
|
| 611 |
-
def INPUT_TYPES(s):
|
| 612 |
-
return {
|
| 613 |
-
"required": {
|
| 614 |
-
"start_frame": model_field_to_node_input(
|
| 615 |
-
IO.IMAGE,
|
| 616 |
-
KlingImage2VideoRequest,
|
| 617 |
-
"image",
|
| 618 |
-
tooltip="The reference image used to generate the video.",
|
| 619 |
-
),
|
| 620 |
-
"prompt": model_field_to_node_input(
|
| 621 |
-
IO.STRING, KlingImage2VideoRequest, "prompt", multiline=True
|
| 622 |
-
),
|
| 623 |
-
"negative_prompt": model_field_to_node_input(
|
| 624 |
-
IO.STRING,
|
| 625 |
-
KlingImage2VideoRequest,
|
| 626 |
-
"negative_prompt",
|
| 627 |
-
multiline=True,
|
| 628 |
-
),
|
| 629 |
-
"model_name": model_field_to_node_input(
|
| 630 |
-
IO.COMBO,
|
| 631 |
-
KlingImage2VideoRequest,
|
| 632 |
-
"model_name",
|
| 633 |
-
enum_type=KlingVideoGenModelName,
|
| 634 |
-
),
|
| 635 |
-
"cfg_scale": model_field_to_node_input(
|
| 636 |
-
IO.FLOAT,
|
| 637 |
-
KlingImage2VideoRequest,
|
| 638 |
-
"cfg_scale",
|
| 639 |
-
default=0.8,
|
| 640 |
-
min=0.0,
|
| 641 |
-
max=1.0,
|
| 642 |
-
),
|
| 643 |
-
"mode": model_field_to_node_input(
|
| 644 |
-
IO.COMBO,
|
| 645 |
-
KlingImage2VideoRequest,
|
| 646 |
-
"mode",
|
| 647 |
-
enum_type=KlingVideoGenMode,
|
| 648 |
-
),
|
| 649 |
-
"aspect_ratio": model_field_to_node_input(
|
| 650 |
-
IO.COMBO,
|
| 651 |
-
KlingImage2VideoRequest,
|
| 652 |
-
"aspect_ratio",
|
| 653 |
-
enum_type=KlingVideoGenAspectRatio,
|
| 654 |
-
),
|
| 655 |
-
"duration": model_field_to_node_input(
|
| 656 |
-
IO.COMBO,
|
| 657 |
-
KlingImage2VideoRequest,
|
| 658 |
-
"duration",
|
| 659 |
-
enum_type=KlingVideoGenDuration,
|
| 660 |
-
),
|
| 661 |
-
},
|
| 662 |
-
"hidden": {
|
| 663 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 664 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 665 |
-
"unique_id": "UNIQUE_ID",
|
| 666 |
-
},
|
| 667 |
-
}
|
| 668 |
-
|
| 669 |
-
RETURN_TYPES = ("VIDEO", "STRING", "STRING")
|
| 670 |
-
RETURN_NAMES = ("VIDEO", "video_id", "duration")
|
| 671 |
-
DESCRIPTION = "Kling Image to Video Node"
|
| 672 |
-
|
| 673 |
-
def get_response(
|
| 674 |
-
self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
|
| 675 |
-
) -> KlingImage2VideoResponse:
|
| 676 |
-
return poll_until_finished(
|
| 677 |
-
auth_kwargs,
|
| 678 |
-
ApiEndpoint(
|
| 679 |
-
path=f"{PATH_IMAGE_TO_VIDEO}/{task_id}",
|
| 680 |
-
method=HttpMethod.GET,
|
| 681 |
-
request_model=KlingImage2VideoRequest,
|
| 682 |
-
response_model=KlingImage2VideoResponse,
|
| 683 |
-
),
|
| 684 |
-
result_url_extractor=get_video_url_from_response,
|
| 685 |
-
estimated_duration=AVERAGE_DURATION_I2V,
|
| 686 |
-
node_id=node_id,
|
| 687 |
-
)
|
| 688 |
-
|
| 689 |
-
def api_call(
|
| 690 |
-
self,
|
| 691 |
-
start_frame: torch.Tensor,
|
| 692 |
-
prompt: str,
|
| 693 |
-
negative_prompt: str,
|
| 694 |
-
model_name: str,
|
| 695 |
-
cfg_scale: float,
|
| 696 |
-
mode: str,
|
| 697 |
-
aspect_ratio: str,
|
| 698 |
-
duration: str,
|
| 699 |
-
camera_control: Optional[KlingCameraControl] = None,
|
| 700 |
-
end_frame: Optional[torch.Tensor] = None,
|
| 701 |
-
unique_id: Optional[str] = None,
|
| 702 |
-
**kwargs,
|
| 703 |
-
) -> tuple[VideoFromFile]:
|
| 704 |
-
validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_I2V)
|
| 705 |
-
validate_input_image(start_frame)
|
| 706 |
-
|
| 707 |
-
if camera_control is not None:
|
| 708 |
-
# Camera control type for image 2 video is always `simple`
|
| 709 |
-
camera_control.type = KlingCameraControlType.simple
|
| 710 |
-
|
| 711 |
-
initial_operation = SynchronousOperation(
|
| 712 |
-
endpoint=ApiEndpoint(
|
| 713 |
-
path=PATH_IMAGE_TO_VIDEO,
|
| 714 |
-
method=HttpMethod.POST,
|
| 715 |
-
request_model=KlingImage2VideoRequest,
|
| 716 |
-
response_model=KlingImage2VideoResponse,
|
| 717 |
-
),
|
| 718 |
-
request=KlingImage2VideoRequest(
|
| 719 |
-
model_name=KlingVideoGenModelName(model_name),
|
| 720 |
-
image=tensor_to_base64_string(start_frame),
|
| 721 |
-
image_tail=(
|
| 722 |
-
tensor_to_base64_string(end_frame)
|
| 723 |
-
if end_frame is not None
|
| 724 |
-
else None
|
| 725 |
-
),
|
| 726 |
-
prompt=prompt,
|
| 727 |
-
negative_prompt=negative_prompt if negative_prompt else None,
|
| 728 |
-
cfg_scale=cfg_scale,
|
| 729 |
-
mode=KlingVideoGenMode(mode),
|
| 730 |
-
duration=KlingVideoGenDuration(duration),
|
| 731 |
-
camera_control=camera_control,
|
| 732 |
-
),
|
| 733 |
-
auth_kwargs=kwargs,
|
| 734 |
-
)
|
| 735 |
-
|
| 736 |
-
task_creation_response = initial_operation.execute()
|
| 737 |
-
validate_task_creation_response(task_creation_response)
|
| 738 |
-
task_id = task_creation_response.data.task_id
|
| 739 |
-
|
| 740 |
-
final_response = self.get_response(
|
| 741 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 742 |
-
)
|
| 743 |
-
validate_video_result_response(final_response)
|
| 744 |
-
|
| 745 |
-
video = get_video_from_response(final_response)
|
| 746 |
-
return video_result_to_node_output(video)
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
class KlingCameraControlI2VNode(KlingImage2VideoNode):
|
| 750 |
-
"""
|
| 751 |
-
Kling Image to Video Camera Control Node. This node is a image to video node, but it supports controlling the camera.
|
| 752 |
-
Duration, mode, and model_name request fields are hard-coded because camera control is only supported in pro mode with the kling-v1-5 model at 5s duration as of 2025-05-02.
|
| 753 |
-
"""
|
| 754 |
-
|
| 755 |
-
@classmethod
|
| 756 |
-
def INPUT_TYPES(s):
|
| 757 |
-
return {
|
| 758 |
-
"required": {
|
| 759 |
-
"start_frame": model_field_to_node_input(
|
| 760 |
-
IO.IMAGE, KlingImage2VideoRequest, "image"
|
| 761 |
-
),
|
| 762 |
-
"prompt": model_field_to_node_input(
|
| 763 |
-
IO.STRING, KlingImage2VideoRequest, "prompt", multiline=True
|
| 764 |
-
),
|
| 765 |
-
"negative_prompt": model_field_to_node_input(
|
| 766 |
-
IO.STRING,
|
| 767 |
-
KlingImage2VideoRequest,
|
| 768 |
-
"negative_prompt",
|
| 769 |
-
multiline=True,
|
| 770 |
-
),
|
| 771 |
-
"cfg_scale": model_field_to_node_input(
|
| 772 |
-
IO.FLOAT,
|
| 773 |
-
KlingImage2VideoRequest,
|
| 774 |
-
"cfg_scale",
|
| 775 |
-
default=0.75,
|
| 776 |
-
min=0.0,
|
| 777 |
-
max=1.0,
|
| 778 |
-
),
|
| 779 |
-
"aspect_ratio": model_field_to_node_input(
|
| 780 |
-
IO.COMBO,
|
| 781 |
-
KlingImage2VideoRequest,
|
| 782 |
-
"aspect_ratio",
|
| 783 |
-
enum_type=KlingVideoGenAspectRatio,
|
| 784 |
-
),
|
| 785 |
-
"camera_control": (
|
| 786 |
-
"CAMERA_CONTROL",
|
| 787 |
-
{
|
| 788 |
-
"tooltip": "Can be created using the Kling Camera Controls node. Controls the camera movement and motion during the video generation.",
|
| 789 |
-
},
|
| 790 |
-
),
|
| 791 |
-
},
|
| 792 |
-
"hidden": {
|
| 793 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 794 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 795 |
-
"unique_id": "UNIQUE_ID",
|
| 796 |
-
},
|
| 797 |
-
}
|
| 798 |
-
|
| 799 |
-
DESCRIPTION = "Transform still images into cinematic videos with professional camera movements that simulate real-world cinematography. Control virtual camera actions including zoom, rotation, pan, tilt, and first-person view, while maintaining focus on your original image."
|
| 800 |
-
|
| 801 |
-
def api_call(
|
| 802 |
-
self,
|
| 803 |
-
start_frame: torch.Tensor,
|
| 804 |
-
prompt: str,
|
| 805 |
-
negative_prompt: str,
|
| 806 |
-
cfg_scale: float,
|
| 807 |
-
aspect_ratio: str,
|
| 808 |
-
camera_control: KlingCameraControl,
|
| 809 |
-
unique_id: Optional[str] = None,
|
| 810 |
-
**kwargs,
|
| 811 |
-
):
|
| 812 |
-
return super().api_call(
|
| 813 |
-
model_name=KlingVideoGenModelName.kling_v1_5,
|
| 814 |
-
start_frame=start_frame,
|
| 815 |
-
cfg_scale=cfg_scale,
|
| 816 |
-
mode=KlingVideoGenMode.pro,
|
| 817 |
-
aspect_ratio=KlingVideoGenAspectRatio(aspect_ratio),
|
| 818 |
-
duration=KlingVideoGenDuration.field_5,
|
| 819 |
-
prompt=prompt,
|
| 820 |
-
negative_prompt=negative_prompt,
|
| 821 |
-
camera_control=camera_control,
|
| 822 |
-
unique_id=unique_id,
|
| 823 |
-
**kwargs,
|
| 824 |
-
)
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
class KlingStartEndFrameNode(KlingImage2VideoNode):
|
| 828 |
-
"""
|
| 829 |
-
Kling First Last Frame Node. This node allows creation of a video from a first and last frame. It calls the normal image to video endpoint, but only allows the subset of input options that support the `image_tail` request field.
|
| 830 |
-
"""
|
| 831 |
-
|
| 832 |
-
@staticmethod
|
| 833 |
-
def get_mode_string_mapping() -> dict[str, tuple[str, str, str]]:
|
| 834 |
-
"""
|
| 835 |
-
Returns a mapping of mode strings to their corresponding (mode, duration, model_name) tuples.
|
| 836 |
-
Only includes config combos that support the `image_tail` request field.
|
| 837 |
-
|
| 838 |
-
See: [Kling API Docs Capability Map](https://app.klingai.com/global/dev/document-api/apiReference/model/skillsMap)
|
| 839 |
-
"""
|
| 840 |
-
return {
|
| 841 |
-
"standard mode / 5s duration / kling-v1": ("std", "5", "kling-v1"),
|
| 842 |
-
"pro mode / 5s duration / kling-v1": ("pro", "5", "kling-v1"),
|
| 843 |
-
"pro mode / 5s duration / kling-v1-5": ("pro", "5", "kling-v1-5"),
|
| 844 |
-
"pro mode / 10s duration / kling-v1-5": ("pro", "10", "kling-v1-5"),
|
| 845 |
-
"pro mode / 5s duration / kling-v1-6": ("pro", "5", "kling-v1-6"),
|
| 846 |
-
"pro mode / 10s duration / kling-v1-6": ("pro", "10", "kling-v1-6"),
|
| 847 |
-
}
|
| 848 |
-
|
| 849 |
-
@classmethod
|
| 850 |
-
def INPUT_TYPES(s):
|
| 851 |
-
modes = list(KlingStartEndFrameNode.get_mode_string_mapping().keys())
|
| 852 |
-
return {
|
| 853 |
-
"required": {
|
| 854 |
-
"start_frame": model_field_to_node_input(
|
| 855 |
-
IO.IMAGE, KlingImage2VideoRequest, "image"
|
| 856 |
-
),
|
| 857 |
-
"end_frame": model_field_to_node_input(
|
| 858 |
-
IO.IMAGE, KlingImage2VideoRequest, "image_tail"
|
| 859 |
-
),
|
| 860 |
-
"prompt": model_field_to_node_input(
|
| 861 |
-
IO.STRING, KlingImage2VideoRequest, "prompt", multiline=True
|
| 862 |
-
),
|
| 863 |
-
"negative_prompt": model_field_to_node_input(
|
| 864 |
-
IO.STRING,
|
| 865 |
-
KlingImage2VideoRequest,
|
| 866 |
-
"negative_prompt",
|
| 867 |
-
multiline=True,
|
| 868 |
-
),
|
| 869 |
-
"cfg_scale": model_field_to_node_input(
|
| 870 |
-
IO.FLOAT,
|
| 871 |
-
KlingImage2VideoRequest,
|
| 872 |
-
"cfg_scale",
|
| 873 |
-
default=0.5,
|
| 874 |
-
min=0.0,
|
| 875 |
-
max=1.0,
|
| 876 |
-
),
|
| 877 |
-
"aspect_ratio": model_field_to_node_input(
|
| 878 |
-
IO.COMBO,
|
| 879 |
-
KlingImage2VideoRequest,
|
| 880 |
-
"aspect_ratio",
|
| 881 |
-
enum_type=KlingVideoGenAspectRatio,
|
| 882 |
-
),
|
| 883 |
-
"mode": (
|
| 884 |
-
modes,
|
| 885 |
-
{
|
| 886 |
-
"default": modes[2],
|
| 887 |
-
"tooltip": "The configuration to use for the video generation following the format: mode / duration / model_name.",
|
| 888 |
-
},
|
| 889 |
-
),
|
| 890 |
-
},
|
| 891 |
-
"hidden": {
|
| 892 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 893 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 894 |
-
"unique_id": "UNIQUE_ID",
|
| 895 |
-
},
|
| 896 |
-
}
|
| 897 |
-
|
| 898 |
-
DESCRIPTION = "Generate a video sequence that transitions between your provided start and end images. The node creates all frames in between, producing a smooth transformation from the first frame to the last."
|
| 899 |
-
|
| 900 |
-
def api_call(
|
| 901 |
-
self,
|
| 902 |
-
start_frame: torch.Tensor,
|
| 903 |
-
end_frame: torch.Tensor,
|
| 904 |
-
prompt: str,
|
| 905 |
-
negative_prompt: str,
|
| 906 |
-
cfg_scale: float,
|
| 907 |
-
aspect_ratio: str,
|
| 908 |
-
mode: str,
|
| 909 |
-
unique_id: Optional[str] = None,
|
| 910 |
-
**kwargs,
|
| 911 |
-
):
|
| 912 |
-
mode, duration, model_name = KlingStartEndFrameNode.get_mode_string_mapping()[
|
| 913 |
-
mode
|
| 914 |
-
]
|
| 915 |
-
return super().api_call(
|
| 916 |
-
prompt=prompt,
|
| 917 |
-
negative_prompt=negative_prompt,
|
| 918 |
-
model_name=model_name,
|
| 919 |
-
start_frame=start_frame,
|
| 920 |
-
cfg_scale=cfg_scale,
|
| 921 |
-
mode=mode,
|
| 922 |
-
aspect_ratio=aspect_ratio,
|
| 923 |
-
duration=duration,
|
| 924 |
-
end_frame=end_frame,
|
| 925 |
-
unique_id=unique_id,
|
| 926 |
-
**kwargs,
|
| 927 |
-
)
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
class KlingVideoExtendNode(KlingNodeBase):
|
| 931 |
-
@classmethod
|
| 932 |
-
def INPUT_TYPES(s):
|
| 933 |
-
return {
|
| 934 |
-
"required": {
|
| 935 |
-
"prompt": model_field_to_node_input(
|
| 936 |
-
IO.STRING, KlingVideoExtendRequest, "prompt", multiline=True
|
| 937 |
-
),
|
| 938 |
-
"negative_prompt": model_field_to_node_input(
|
| 939 |
-
IO.STRING,
|
| 940 |
-
KlingVideoExtendRequest,
|
| 941 |
-
"negative_prompt",
|
| 942 |
-
multiline=True,
|
| 943 |
-
),
|
| 944 |
-
"cfg_scale": model_field_to_node_input(
|
| 945 |
-
IO.FLOAT,
|
| 946 |
-
KlingVideoExtendRequest,
|
| 947 |
-
"cfg_scale",
|
| 948 |
-
default=0.5,
|
| 949 |
-
min=0.0,
|
| 950 |
-
max=1.0,
|
| 951 |
-
),
|
| 952 |
-
"video_id": model_field_to_node_input(
|
| 953 |
-
IO.STRING, KlingVideoExtendRequest, "video_id", forceInput=True
|
| 954 |
-
),
|
| 955 |
-
},
|
| 956 |
-
"hidden": {
|
| 957 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 958 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 959 |
-
"unique_id": "UNIQUE_ID",
|
| 960 |
-
},
|
| 961 |
-
}
|
| 962 |
-
|
| 963 |
-
RETURN_TYPES = ("VIDEO", "STRING", "STRING")
|
| 964 |
-
RETURN_NAMES = ("VIDEO", "video_id", "duration")
|
| 965 |
-
DESCRIPTION = "Kling Video Extend Node. Extend videos made by other Kling nodes. The video_id is created by using other Kling Nodes."
|
| 966 |
-
|
| 967 |
-
def get_response(
|
| 968 |
-
self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
|
| 969 |
-
) -> KlingVideoExtendResponse:
|
| 970 |
-
return poll_until_finished(
|
| 971 |
-
auth_kwargs,
|
| 972 |
-
ApiEndpoint(
|
| 973 |
-
path=f"{PATH_VIDEO_EXTEND}/{task_id}",
|
| 974 |
-
method=HttpMethod.GET,
|
| 975 |
-
request_model=EmptyRequest,
|
| 976 |
-
response_model=KlingVideoExtendResponse,
|
| 977 |
-
),
|
| 978 |
-
result_url_extractor=get_video_url_from_response,
|
| 979 |
-
estimated_duration=AVERAGE_DURATION_VIDEO_EXTEND,
|
| 980 |
-
node_id=node_id,
|
| 981 |
-
)
|
| 982 |
-
|
| 983 |
-
def api_call(
|
| 984 |
-
self,
|
| 985 |
-
prompt: str,
|
| 986 |
-
negative_prompt: str,
|
| 987 |
-
cfg_scale: float,
|
| 988 |
-
video_id: str,
|
| 989 |
-
unique_id: Optional[str] = None,
|
| 990 |
-
**kwargs,
|
| 991 |
-
) -> tuple[VideoFromFile, str, str]:
|
| 992 |
-
validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V)
|
| 993 |
-
initial_operation = SynchronousOperation(
|
| 994 |
-
endpoint=ApiEndpoint(
|
| 995 |
-
path=PATH_VIDEO_EXTEND,
|
| 996 |
-
method=HttpMethod.POST,
|
| 997 |
-
request_model=KlingVideoExtendRequest,
|
| 998 |
-
response_model=KlingVideoExtendResponse,
|
| 999 |
-
),
|
| 1000 |
-
request=KlingVideoExtendRequest(
|
| 1001 |
-
prompt=prompt if prompt else None,
|
| 1002 |
-
negative_prompt=negative_prompt if negative_prompt else None,
|
| 1003 |
-
cfg_scale=cfg_scale,
|
| 1004 |
-
video_id=video_id,
|
| 1005 |
-
),
|
| 1006 |
-
auth_kwargs=kwargs,
|
| 1007 |
-
)
|
| 1008 |
-
|
| 1009 |
-
task_creation_response = initial_operation.execute()
|
| 1010 |
-
validate_task_creation_response(task_creation_response)
|
| 1011 |
-
task_id = task_creation_response.data.task_id
|
| 1012 |
-
|
| 1013 |
-
final_response = self.get_response(
|
| 1014 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 1015 |
-
)
|
| 1016 |
-
validate_video_result_response(final_response)
|
| 1017 |
-
|
| 1018 |
-
video = get_video_from_response(final_response)
|
| 1019 |
-
return video_result_to_node_output(video)
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
class KlingVideoEffectsBase(KlingNodeBase):
|
| 1023 |
-
"""Kling Video Effects Base"""
|
| 1024 |
-
|
| 1025 |
-
RETURN_TYPES = ("VIDEO", "STRING", "STRING")
|
| 1026 |
-
RETURN_NAMES = ("VIDEO", "video_id", "duration")
|
| 1027 |
-
|
| 1028 |
-
def get_response(
|
| 1029 |
-
self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
|
| 1030 |
-
) -> KlingVideoEffectsResponse:
|
| 1031 |
-
return poll_until_finished(
|
| 1032 |
-
auth_kwargs,
|
| 1033 |
-
ApiEndpoint(
|
| 1034 |
-
path=f"{PATH_VIDEO_EFFECTS}/{task_id}",
|
| 1035 |
-
method=HttpMethod.GET,
|
| 1036 |
-
request_model=EmptyRequest,
|
| 1037 |
-
response_model=KlingVideoEffectsResponse,
|
| 1038 |
-
),
|
| 1039 |
-
result_url_extractor=get_video_url_from_response,
|
| 1040 |
-
estimated_duration=AVERAGE_DURATION_VIDEO_EFFECTS,
|
| 1041 |
-
node_id=node_id,
|
| 1042 |
-
)
|
| 1043 |
-
|
| 1044 |
-
def api_call(
|
| 1045 |
-
self,
|
| 1046 |
-
dual_character: bool,
|
| 1047 |
-
effect_scene: KlingDualCharacterEffectsScene | KlingSingleImageEffectsScene,
|
| 1048 |
-
model_name: str,
|
| 1049 |
-
duration: KlingVideoGenDuration,
|
| 1050 |
-
image_1: torch.Tensor,
|
| 1051 |
-
image_2: Optional[torch.Tensor] = None,
|
| 1052 |
-
mode: Optional[KlingVideoGenMode] = None,
|
| 1053 |
-
unique_id: Optional[str] = None,
|
| 1054 |
-
**kwargs,
|
| 1055 |
-
):
|
| 1056 |
-
if dual_character:
|
| 1057 |
-
request_input_field = KlingDualCharacterEffectInput(
|
| 1058 |
-
model_name=model_name,
|
| 1059 |
-
mode=mode,
|
| 1060 |
-
images=[
|
| 1061 |
-
tensor_to_base64_string(image_1),
|
| 1062 |
-
tensor_to_base64_string(image_2),
|
| 1063 |
-
],
|
| 1064 |
-
duration=duration,
|
| 1065 |
-
)
|
| 1066 |
-
else:
|
| 1067 |
-
request_input_field = KlingSingleImageEffectInput(
|
| 1068 |
-
model_name=model_name,
|
| 1069 |
-
image=tensor_to_base64_string(image_1),
|
| 1070 |
-
duration=duration,
|
| 1071 |
-
)
|
| 1072 |
-
|
| 1073 |
-
initial_operation = SynchronousOperation(
|
| 1074 |
-
endpoint=ApiEndpoint(
|
| 1075 |
-
path=PATH_VIDEO_EFFECTS,
|
| 1076 |
-
method=HttpMethod.POST,
|
| 1077 |
-
request_model=KlingVideoEffectsRequest,
|
| 1078 |
-
response_model=KlingVideoEffectsResponse,
|
| 1079 |
-
),
|
| 1080 |
-
request=KlingVideoEffectsRequest(
|
| 1081 |
-
effect_scene=effect_scene,
|
| 1082 |
-
input=request_input_field,
|
| 1083 |
-
),
|
| 1084 |
-
auth_kwargs=kwargs,
|
| 1085 |
-
)
|
| 1086 |
-
|
| 1087 |
-
task_creation_response = initial_operation.execute()
|
| 1088 |
-
validate_task_creation_response(task_creation_response)
|
| 1089 |
-
task_id = task_creation_response.data.task_id
|
| 1090 |
-
|
| 1091 |
-
final_response = self.get_response(
|
| 1092 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 1093 |
-
)
|
| 1094 |
-
validate_video_result_response(final_response)
|
| 1095 |
-
|
| 1096 |
-
video = get_video_from_response(final_response)
|
| 1097 |
-
return video_result_to_node_output(video)
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
class KlingDualCharacterVideoEffectNode(KlingVideoEffectsBase):
|
| 1101 |
-
"""Kling Dual Character Video Effect Node"""
|
| 1102 |
-
|
| 1103 |
-
@classmethod
|
| 1104 |
-
def INPUT_TYPES(s):
|
| 1105 |
-
return {
|
| 1106 |
-
"required": {
|
| 1107 |
-
"image_left": (IO.IMAGE, {"tooltip": "Left side image"}),
|
| 1108 |
-
"image_right": (IO.IMAGE, {"tooltip": "Right side image"}),
|
| 1109 |
-
"effect_scene": model_field_to_node_input(
|
| 1110 |
-
IO.COMBO,
|
| 1111 |
-
KlingVideoEffectsRequest,
|
| 1112 |
-
"effect_scene",
|
| 1113 |
-
enum_type=KlingDualCharacterEffectsScene,
|
| 1114 |
-
),
|
| 1115 |
-
"model_name": model_field_to_node_input(
|
| 1116 |
-
IO.COMBO,
|
| 1117 |
-
KlingDualCharacterEffectInput,
|
| 1118 |
-
"model_name",
|
| 1119 |
-
enum_type=KlingCharacterEffectModelName,
|
| 1120 |
-
),
|
| 1121 |
-
"mode": model_field_to_node_input(
|
| 1122 |
-
IO.COMBO,
|
| 1123 |
-
KlingDualCharacterEffectInput,
|
| 1124 |
-
"mode",
|
| 1125 |
-
enum_type=KlingVideoGenMode,
|
| 1126 |
-
),
|
| 1127 |
-
"duration": model_field_to_node_input(
|
| 1128 |
-
IO.COMBO,
|
| 1129 |
-
KlingDualCharacterEffectInput,
|
| 1130 |
-
"duration",
|
| 1131 |
-
enum_type=KlingVideoGenDuration,
|
| 1132 |
-
),
|
| 1133 |
-
},
|
| 1134 |
-
"hidden": {
|
| 1135 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 1136 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 1137 |
-
"unique_id": "UNIQUE_ID",
|
| 1138 |
-
},
|
| 1139 |
-
}
|
| 1140 |
-
|
| 1141 |
-
DESCRIPTION = "Achieve different special effects when generating a video based on the effect_scene. First image will be positioned on left side, second on right side of the composite."
|
| 1142 |
-
RETURN_TYPES = ("VIDEO", "STRING")
|
| 1143 |
-
RETURN_NAMES = ("VIDEO", "duration")
|
| 1144 |
-
|
| 1145 |
-
def api_call(
|
| 1146 |
-
self,
|
| 1147 |
-
image_left: torch.Tensor,
|
| 1148 |
-
image_right: torch.Tensor,
|
| 1149 |
-
effect_scene: KlingDualCharacterEffectsScene,
|
| 1150 |
-
model_name: KlingCharacterEffectModelName,
|
| 1151 |
-
mode: KlingVideoGenMode,
|
| 1152 |
-
duration: KlingVideoGenDuration,
|
| 1153 |
-
unique_id: Optional[str] = None,
|
| 1154 |
-
**kwargs,
|
| 1155 |
-
):
|
| 1156 |
-
video, _, duration = super().api_call(
|
| 1157 |
-
dual_character=True,
|
| 1158 |
-
effect_scene=effect_scene,
|
| 1159 |
-
model_name=model_name,
|
| 1160 |
-
mode=mode,
|
| 1161 |
-
duration=duration,
|
| 1162 |
-
image_1=image_left,
|
| 1163 |
-
image_2=image_right,
|
| 1164 |
-
unique_id=unique_id,
|
| 1165 |
-
**kwargs,
|
| 1166 |
-
)
|
| 1167 |
-
return video, duration
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase):
|
| 1171 |
-
"""Kling Single Image Video Effect Node"""
|
| 1172 |
-
|
| 1173 |
-
@classmethod
|
| 1174 |
-
def INPUT_TYPES(s):
|
| 1175 |
-
return {
|
| 1176 |
-
"required": {
|
| 1177 |
-
"image": (
|
| 1178 |
-
IO.IMAGE,
|
| 1179 |
-
{
|
| 1180 |
-
"tooltip": " Reference Image. URL or Base64 encoded string (without data:image prefix). File size cannot exceed 10MB, resolution not less than 300*300px, aspect ratio between 1:2.5 ~ 2.5:1"
|
| 1181 |
-
},
|
| 1182 |
-
),
|
| 1183 |
-
"effect_scene": model_field_to_node_input(
|
| 1184 |
-
IO.COMBO,
|
| 1185 |
-
KlingVideoEffectsRequest,
|
| 1186 |
-
"effect_scene",
|
| 1187 |
-
enum_type=KlingSingleImageEffectsScene,
|
| 1188 |
-
),
|
| 1189 |
-
"model_name": model_field_to_node_input(
|
| 1190 |
-
IO.COMBO,
|
| 1191 |
-
KlingSingleImageEffectInput,
|
| 1192 |
-
"model_name",
|
| 1193 |
-
enum_type=KlingSingleImageEffectModelName,
|
| 1194 |
-
),
|
| 1195 |
-
"duration": model_field_to_node_input(
|
| 1196 |
-
IO.COMBO,
|
| 1197 |
-
KlingSingleImageEffectInput,
|
| 1198 |
-
"duration",
|
| 1199 |
-
enum_type=KlingVideoGenDuration,
|
| 1200 |
-
),
|
| 1201 |
-
},
|
| 1202 |
-
"hidden": {
|
| 1203 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 1204 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 1205 |
-
"unique_id": "UNIQUE_ID",
|
| 1206 |
-
},
|
| 1207 |
-
}
|
| 1208 |
-
|
| 1209 |
-
DESCRIPTION = "Achieve different special effects when generating a video based on the effect_scene."
|
| 1210 |
-
|
| 1211 |
-
def api_call(
|
| 1212 |
-
self,
|
| 1213 |
-
image: torch.Tensor,
|
| 1214 |
-
effect_scene: KlingSingleImageEffectsScene,
|
| 1215 |
-
model_name: KlingSingleImageEffectModelName,
|
| 1216 |
-
duration: KlingVideoGenDuration,
|
| 1217 |
-
unique_id: Optional[str] = None,
|
| 1218 |
-
**kwargs,
|
| 1219 |
-
):
|
| 1220 |
-
return super().api_call(
|
| 1221 |
-
dual_character=False,
|
| 1222 |
-
effect_scene=effect_scene,
|
| 1223 |
-
model_name=model_name,
|
| 1224 |
-
duration=duration,
|
| 1225 |
-
image_1=image,
|
| 1226 |
-
unique_id=unique_id,
|
| 1227 |
-
**kwargs,
|
| 1228 |
-
)
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
class KlingLipSyncBase(KlingNodeBase):
|
| 1232 |
-
"""Kling Lip Sync Base"""
|
| 1233 |
-
|
| 1234 |
-
RETURN_TYPES = ("VIDEO", "STRING", "STRING")
|
| 1235 |
-
RETURN_NAMES = ("VIDEO", "video_id", "duration")
|
| 1236 |
-
|
| 1237 |
-
def validate_lip_sync_video(self, video: VideoInput):
|
| 1238 |
-
"""
|
| 1239 |
-
Validates the input video adheres to the expectations of the Kling Lip Sync API:
|
| 1240 |
-
- Video length does not exceed 10s and is not shorter than 2s
|
| 1241 |
-
- Length and width dimensions should both be between 720px and 1920px
|
| 1242 |
-
|
| 1243 |
-
See: https://app.klingai.com/global/dev/document-api/apiReference/model/videoTolip
|
| 1244 |
-
"""
|
| 1245 |
-
validate_video_dimensions(video, 720, 1920)
|
| 1246 |
-
validate_video_duration(video, 2, 10)
|
| 1247 |
-
|
| 1248 |
-
def validate_text(self, text: str):
|
| 1249 |
-
if not text:
|
| 1250 |
-
raise ValueError("Text is required")
|
| 1251 |
-
if len(text) > MAX_PROMPT_LENGTH_LIP_SYNC:
|
| 1252 |
-
raise ValueError(
|
| 1253 |
-
f"Text is too long. Maximum length is {MAX_PROMPT_LENGTH_LIP_SYNC} characters."
|
| 1254 |
-
)
|
| 1255 |
-
|
| 1256 |
-
def get_response(
|
| 1257 |
-
self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
|
| 1258 |
-
) -> KlingLipSyncResponse:
|
| 1259 |
-
"""Polls the Kling API endpoint until the task reaches a terminal state."""
|
| 1260 |
-
return poll_until_finished(
|
| 1261 |
-
auth_kwargs,
|
| 1262 |
-
ApiEndpoint(
|
| 1263 |
-
path=f"{PATH_LIP_SYNC}/{task_id}",
|
| 1264 |
-
method=HttpMethod.GET,
|
| 1265 |
-
request_model=EmptyRequest,
|
| 1266 |
-
response_model=KlingLipSyncResponse,
|
| 1267 |
-
),
|
| 1268 |
-
result_url_extractor=get_video_url_from_response,
|
| 1269 |
-
estimated_duration=AVERAGE_DURATION_LIP_SYNC,
|
| 1270 |
-
node_id=node_id,
|
| 1271 |
-
)
|
| 1272 |
-
|
| 1273 |
-
def api_call(
|
| 1274 |
-
self,
|
| 1275 |
-
video: VideoInput,
|
| 1276 |
-
audio: Optional[AudioInput] = None,
|
| 1277 |
-
voice_language: Optional[str] = None,
|
| 1278 |
-
mode: Optional[str] = None,
|
| 1279 |
-
text: Optional[str] = None,
|
| 1280 |
-
voice_speed: Optional[float] = None,
|
| 1281 |
-
voice_id: Optional[str] = None,
|
| 1282 |
-
unique_id: Optional[str] = None,
|
| 1283 |
-
**kwargs,
|
| 1284 |
-
) -> tuple[VideoFromFile, str, str]:
|
| 1285 |
-
if text:
|
| 1286 |
-
self.validate_text(text)
|
| 1287 |
-
self.validate_lip_sync_video(video)
|
| 1288 |
-
|
| 1289 |
-
# Upload video to Comfy API and get download URL
|
| 1290 |
-
video_url = upload_video_to_comfyapi(video, auth_kwargs=kwargs)
|
| 1291 |
-
logging.info("Uploaded video to Comfy API. URL: %s", video_url)
|
| 1292 |
-
|
| 1293 |
-
# Upload the audio file to Comfy API and get download URL
|
| 1294 |
-
if audio:
|
| 1295 |
-
audio_url = upload_audio_to_comfyapi(audio, auth_kwargs=kwargs)
|
| 1296 |
-
logging.info("Uploaded audio to Comfy API. URL: %s", audio_url)
|
| 1297 |
-
else:
|
| 1298 |
-
audio_url = None
|
| 1299 |
-
|
| 1300 |
-
initial_operation = SynchronousOperation(
|
| 1301 |
-
endpoint=ApiEndpoint(
|
| 1302 |
-
path=PATH_LIP_SYNC,
|
| 1303 |
-
method=HttpMethod.POST,
|
| 1304 |
-
request_model=KlingLipSyncRequest,
|
| 1305 |
-
response_model=KlingLipSyncResponse,
|
| 1306 |
-
),
|
| 1307 |
-
request=KlingLipSyncRequest(
|
| 1308 |
-
input=KlingLipSyncInputObject(
|
| 1309 |
-
video_url=video_url,
|
| 1310 |
-
mode=mode,
|
| 1311 |
-
text=text,
|
| 1312 |
-
voice_language=voice_language,
|
| 1313 |
-
voice_speed=voice_speed,
|
| 1314 |
-
audio_type="url",
|
| 1315 |
-
audio_url=audio_url,
|
| 1316 |
-
voice_id=voice_id,
|
| 1317 |
-
),
|
| 1318 |
-
),
|
| 1319 |
-
auth_kwargs=kwargs,
|
| 1320 |
-
)
|
| 1321 |
-
|
| 1322 |
-
task_creation_response = initial_operation.execute()
|
| 1323 |
-
validate_task_creation_response(task_creation_response)
|
| 1324 |
-
task_id = task_creation_response.data.task_id
|
| 1325 |
-
|
| 1326 |
-
final_response = self.get_response(
|
| 1327 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 1328 |
-
)
|
| 1329 |
-
validate_video_result_response(final_response)
|
| 1330 |
-
|
| 1331 |
-
video = get_video_from_response(final_response)
|
| 1332 |
-
return video_result_to_node_output(video)
|
| 1333 |
-
|
| 1334 |
-
|
| 1335 |
-
class KlingLipSyncAudioToVideoNode(KlingLipSyncBase):
|
| 1336 |
-
"""Kling Lip Sync Audio to Video Node. Syncs mouth movements in a video file to the audio content of an audio file."""
|
| 1337 |
-
|
| 1338 |
-
@classmethod
|
| 1339 |
-
def INPUT_TYPES(s):
|
| 1340 |
-
return {
|
| 1341 |
-
"required": {
|
| 1342 |
-
"video": (IO.VIDEO, {}),
|
| 1343 |
-
"audio": (IO.AUDIO, {}),
|
| 1344 |
-
"voice_language": model_field_to_node_input(
|
| 1345 |
-
IO.COMBO,
|
| 1346 |
-
KlingLipSyncInputObject,
|
| 1347 |
-
"voice_language",
|
| 1348 |
-
enum_type=KlingLipSyncVoiceLanguage,
|
| 1349 |
-
),
|
| 1350 |
-
},
|
| 1351 |
-
"hidden": {
|
| 1352 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 1353 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 1354 |
-
"unique_id": "UNIQUE_ID",
|
| 1355 |
-
},
|
| 1356 |
-
}
|
| 1357 |
-
|
| 1358 |
-
DESCRIPTION = "Kling Lip Sync Audio to Video Node. Syncs mouth movements in a video file to the audio content of an audio file. When using, ensure that the audio contains clearly distinguishable vocals and that the video contains a distinct face. The audio file should not be larger than 5MB. The video file should not be larger than 100MB, should have height/width between 720px and 1920px, and should be between 2s and 10s in length."
|
| 1359 |
-
|
| 1360 |
-
def api_call(
|
| 1361 |
-
self,
|
| 1362 |
-
video: VideoInput,
|
| 1363 |
-
audio: AudioInput,
|
| 1364 |
-
voice_language: str,
|
| 1365 |
-
unique_id: Optional[str] = None,
|
| 1366 |
-
**kwargs,
|
| 1367 |
-
):
|
| 1368 |
-
return super().api_call(
|
| 1369 |
-
video=video,
|
| 1370 |
-
audio=audio,
|
| 1371 |
-
voice_language=voice_language,
|
| 1372 |
-
mode="audio2video",
|
| 1373 |
-
unique_id=unique_id,
|
| 1374 |
-
**kwargs,
|
| 1375 |
-
)
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
class KlingLipSyncTextToVideoNode(KlingLipSyncBase):
|
| 1379 |
-
"""Kling Lip Sync Text to Video Node. Syncs mouth movements in a video file to a text prompt."""
|
| 1380 |
-
|
| 1381 |
-
@staticmethod
|
| 1382 |
-
def get_voice_config() -> dict[str, tuple[str, str]]:
|
| 1383 |
-
return {
|
| 1384 |
-
# English voices
|
| 1385 |
-
"Melody": ("girlfriend_4_speech02", "en"),
|
| 1386 |
-
"Sunny": ("genshin_vindi2", "en"),
|
| 1387 |
-
"Sage": ("zhinen_xuesheng", "en"),
|
| 1388 |
-
"Ace": ("AOT", "en"),
|
| 1389 |
-
"Blossom": ("ai_shatang", "en"),
|
| 1390 |
-
"Peppy": ("genshin_klee2", "en"),
|
| 1391 |
-
"Dove": ("genshin_kirara", "en"),
|
| 1392 |
-
"Shine": ("ai_kaiya", "en"),
|
| 1393 |
-
"Anchor": ("oversea_male1", "en"),
|
| 1394 |
-
"Lyric": ("ai_chenjiahao_712", "en"),
|
| 1395 |
-
"Tender": ("chat1_female_new-3", "en"),
|
| 1396 |
-
"Siren": ("chat_0407_5-1", "en"),
|
| 1397 |
-
"Zippy": ("cartoon-boy-07", "en"),
|
| 1398 |
-
"Bud": ("uk_boy1", "en"),
|
| 1399 |
-
"Sprite": ("cartoon-girl-01", "en"),
|
| 1400 |
-
"Candy": ("PeppaPig_platform", "en"),
|
| 1401 |
-
"Beacon": ("ai_huangzhong_712", "en"),
|
| 1402 |
-
"Rock": ("ai_huangyaoshi_712", "en"),
|
| 1403 |
-
"Titan": ("ai_laoguowang_712", "en"),
|
| 1404 |
-
"Grace": ("chengshu_jiejie", "en"),
|
| 1405 |
-
"Helen": ("you_pingjing", "en"),
|
| 1406 |
-
"Lore": ("calm_story1", "en"),
|
| 1407 |
-
"Crag": ("uk_man2", "en"),
|
| 1408 |
-
"Prattle": ("laopopo_speech02", "en"),
|
| 1409 |
-
"Hearth": ("heainainai_speech02", "en"),
|
| 1410 |
-
"The Reader": ("reader_en_m-v1", "en"),
|
| 1411 |
-
"Commercial Lady": ("commercial_lady_en_f-v1", "en"),
|
| 1412 |
-
# Chinese voices
|
| 1413 |
-
"阳光少年": ("genshin_vindi2", "zh"),
|
| 1414 |
-
"懂事小弟": ("zhinen_xuesheng", "zh"),
|
| 1415 |
-
"运动少年": ("tiyuxi_xuedi", "zh"),
|
| 1416 |
-
"青春少女": ("ai_shatang", "zh"),
|
| 1417 |
-
"温柔小妹": ("genshin_klee2", "zh"),
|
| 1418 |
-
"元气少女": ("genshin_kirara", "zh"),
|
| 1419 |
-
"阳光男生": ("ai_kaiya", "zh"),
|
| 1420 |
-
"幽默小哥": ("tiexin_nanyou", "zh"),
|
| 1421 |
-
"文艺小哥": ("ai_chenjiahao_712", "zh"),
|
| 1422 |
-
"甜美邻家": ("girlfriend_1_speech02", "zh"),
|
| 1423 |
-
"温柔姐姐": ("chat1_female_new-3", "zh"),
|
| 1424 |
-
"职场女青": ("girlfriend_2_speech02", "zh"),
|
| 1425 |
-
"活泼男童": ("cartoon-boy-07", "zh"),
|
| 1426 |
-
"俏皮女童": ("cartoon-girl-01", "zh"),
|
| 1427 |
-
"稳重老爸": ("ai_huangyaoshi_712", "zh"),
|
| 1428 |
-
"温柔妈妈": ("you_pingjing", "zh"),
|
| 1429 |
-
"严肃上司": ("ai_laoguowang_712", "zh"),
|
| 1430 |
-
"优雅贵妇": ("chengshu_jiejie", "zh"),
|
| 1431 |
-
"慈祥爷爷": ("zhuxi_speech02", "zh"),
|
| 1432 |
-
"唠叨爷爷": ("uk_oldman3", "zh"),
|
| 1433 |
-
"唠叨奶奶": ("laopopo_speech02", "zh"),
|
| 1434 |
-
"和蔼奶奶": ("heainainai_speech02", "zh"),
|
| 1435 |
-
"东北老铁": ("dongbeilaotie_speech02", "zh"),
|
| 1436 |
-
"重庆小伙": ("chongqingxiaohuo_speech02", "zh"),
|
| 1437 |
-
"四川妹子": ("chuanmeizi_speech02", "zh"),
|
| 1438 |
-
"潮汕大叔": ("chaoshandashu_speech02", "zh"),
|
| 1439 |
-
"台湾男生": ("ai_taiwan_man2_speech02", "zh"),
|
| 1440 |
-
"西安掌柜": ("xianzhanggui_speech02", "zh"),
|
| 1441 |
-
"天津姐姐": ("tianjinjiejie_speech02", "zh"),
|
| 1442 |
-
"新闻播报男": ("diyinnansang_DB_CN_M_04-v2", "zh"),
|
| 1443 |
-
"译制片男": ("yizhipiannan-v1", "zh"),
|
| 1444 |
-
"撒娇女友": ("tianmeixuemei-v1", "zh"),
|
| 1445 |
-
"刀片烟嗓": ("daopianyansang-v1", "zh"),
|
| 1446 |
-
"乖巧正太": ("mengwa-v1", "zh"),
|
| 1447 |
-
}
|
| 1448 |
-
|
| 1449 |
-
@classmethod
|
| 1450 |
-
def INPUT_TYPES(s):
|
| 1451 |
-
voice_options = list(s.get_voice_config().keys())
|
| 1452 |
-
return {
|
| 1453 |
-
"required": {
|
| 1454 |
-
"video": (IO.VIDEO, {}),
|
| 1455 |
-
"text": model_field_to_node_input(
|
| 1456 |
-
IO.STRING, KlingLipSyncInputObject, "text", multiline=True
|
| 1457 |
-
),
|
| 1458 |
-
"voice": (voice_options, {"default": voice_options[0]}),
|
| 1459 |
-
"voice_speed": model_field_to_node_input(
|
| 1460 |
-
IO.FLOAT, KlingLipSyncInputObject, "voice_speed", slider=True
|
| 1461 |
-
),
|
| 1462 |
-
},
|
| 1463 |
-
"hidden": {
|
| 1464 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 1465 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 1466 |
-
"unique_id": "UNIQUE_ID",
|
| 1467 |
-
},
|
| 1468 |
-
}
|
| 1469 |
-
|
| 1470 |
-
DESCRIPTION = "Kling Lip Sync Text to Video Node. Syncs mouth movements in a video file to a text prompt. The video file should not be larger than 100MB, should have height/width between 720px and 1920px, and should be between 2s and 10s in length."
|
| 1471 |
-
|
| 1472 |
-
def api_call(
|
| 1473 |
-
self,
|
| 1474 |
-
video: VideoInput,
|
| 1475 |
-
text: str,
|
| 1476 |
-
voice: str,
|
| 1477 |
-
voice_speed: float,
|
| 1478 |
-
unique_id: Optional[str] = None,
|
| 1479 |
-
**kwargs,
|
| 1480 |
-
):
|
| 1481 |
-
voice_id, voice_language = KlingLipSyncTextToVideoNode.get_voice_config()[voice]
|
| 1482 |
-
return super().api_call(
|
| 1483 |
-
video=video,
|
| 1484 |
-
text=text,
|
| 1485 |
-
voice_language=voice_language,
|
| 1486 |
-
voice_id=voice_id,
|
| 1487 |
-
voice_speed=voice_speed,
|
| 1488 |
-
mode="text2video",
|
| 1489 |
-
unique_id=unique_id,
|
| 1490 |
-
**kwargs,
|
| 1491 |
-
)
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
class KlingImageGenerationBase(KlingNodeBase):
|
| 1495 |
-
"""Kling Image Generation Base Node."""
|
| 1496 |
-
|
| 1497 |
-
RETURN_TYPES = ("IMAGE",)
|
| 1498 |
-
CATEGORY = "api node/image/Kling"
|
| 1499 |
-
|
| 1500 |
-
def validate_prompt(self, prompt: str, negative_prompt: Optional[str] = None):
|
| 1501 |
-
if not prompt or len(prompt) > MAX_PROMPT_LENGTH_IMAGE_GEN:
|
| 1502 |
-
raise ValueError(
|
| 1503 |
-
f"Prompt must be less than {MAX_PROMPT_LENGTH_IMAGE_GEN} characters"
|
| 1504 |
-
)
|
| 1505 |
-
if negative_prompt and len(negative_prompt) > MAX_PROMPT_LENGTH_IMAGE_GEN:
|
| 1506 |
-
raise ValueError(
|
| 1507 |
-
f"Negative prompt must be less than {MAX_PROMPT_LENGTH_IMAGE_GEN} characters"
|
| 1508 |
-
)
|
| 1509 |
-
|
| 1510 |
-
|
| 1511 |
-
class KlingVirtualTryOnNode(KlingImageGenerationBase):
|
| 1512 |
-
"""Kling Virtual Try On Node."""
|
| 1513 |
-
|
| 1514 |
-
@classmethod
|
| 1515 |
-
def INPUT_TYPES(s):
|
| 1516 |
-
return {
|
| 1517 |
-
"required": {
|
| 1518 |
-
"human_image": (IO.IMAGE, {}),
|
| 1519 |
-
"cloth_image": (IO.IMAGE, {}),
|
| 1520 |
-
"model_name": model_field_to_node_input(
|
| 1521 |
-
IO.COMBO,
|
| 1522 |
-
KlingVirtualTryOnRequest,
|
| 1523 |
-
"model_name",
|
| 1524 |
-
enum_type=KlingVirtualTryOnModelName,
|
| 1525 |
-
),
|
| 1526 |
-
},
|
| 1527 |
-
"hidden": {
|
| 1528 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 1529 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 1530 |
-
"unique_id": "UNIQUE_ID",
|
| 1531 |
-
},
|
| 1532 |
-
}
|
| 1533 |
-
|
| 1534 |
-
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."
|
| 1535 |
-
|
| 1536 |
-
def get_response(
|
| 1537 |
-
self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
|
| 1538 |
-
) -> KlingVirtualTryOnResponse:
|
| 1539 |
-
return poll_until_finished(
|
| 1540 |
-
auth_kwargs,
|
| 1541 |
-
ApiEndpoint(
|
| 1542 |
-
path=f"{PATH_VIRTUAL_TRY_ON}/{task_id}",
|
| 1543 |
-
method=HttpMethod.GET,
|
| 1544 |
-
request_model=EmptyRequest,
|
| 1545 |
-
response_model=KlingVirtualTryOnResponse,
|
| 1546 |
-
),
|
| 1547 |
-
result_url_extractor=get_images_urls_from_response,
|
| 1548 |
-
estimated_duration=AVERAGE_DURATION_VIRTUAL_TRY_ON,
|
| 1549 |
-
node_id=node_id,
|
| 1550 |
-
)
|
| 1551 |
-
|
| 1552 |
-
def api_call(
|
| 1553 |
-
self,
|
| 1554 |
-
human_image: torch.Tensor,
|
| 1555 |
-
cloth_image: torch.Tensor,
|
| 1556 |
-
model_name: KlingVirtualTryOnModelName,
|
| 1557 |
-
unique_id: Optional[str] = None,
|
| 1558 |
-
**kwargs,
|
| 1559 |
-
):
|
| 1560 |
-
initial_operation = SynchronousOperation(
|
| 1561 |
-
endpoint=ApiEndpoint(
|
| 1562 |
-
path=PATH_VIRTUAL_TRY_ON,
|
| 1563 |
-
method=HttpMethod.POST,
|
| 1564 |
-
request_model=KlingVirtualTryOnRequest,
|
| 1565 |
-
response_model=KlingVirtualTryOnResponse,
|
| 1566 |
-
),
|
| 1567 |
-
request=KlingVirtualTryOnRequest(
|
| 1568 |
-
human_image=tensor_to_base64_string(human_image),
|
| 1569 |
-
cloth_image=tensor_to_base64_string(cloth_image),
|
| 1570 |
-
model_name=model_name,
|
| 1571 |
-
),
|
| 1572 |
-
auth_kwargs=kwargs,
|
| 1573 |
-
)
|
| 1574 |
-
|
| 1575 |
-
task_creation_response = initial_operation.execute()
|
| 1576 |
-
validate_task_creation_response(task_creation_response)
|
| 1577 |
-
task_id = task_creation_response.data.task_id
|
| 1578 |
-
|
| 1579 |
-
final_response = self.get_response(
|
| 1580 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 1581 |
-
)
|
| 1582 |
-
validate_image_result_response(final_response)
|
| 1583 |
-
|
| 1584 |
-
images = get_images_from_response(final_response)
|
| 1585 |
-
return (image_result_to_node_output(images),)
|
| 1586 |
-
|
| 1587 |
-
|
| 1588 |
-
class KlingImageGenerationNode(KlingImageGenerationBase):
|
| 1589 |
-
"""Kling Image Generation Node. Generate an image from a text prompt with an optional reference image."""
|
| 1590 |
-
|
| 1591 |
-
@classmethod
|
| 1592 |
-
def INPUT_TYPES(s):
|
| 1593 |
-
return {
|
| 1594 |
-
"required": {
|
| 1595 |
-
"prompt": model_field_to_node_input(
|
| 1596 |
-
IO.STRING,
|
| 1597 |
-
KlingImageGenerationsRequest,
|
| 1598 |
-
"prompt",
|
| 1599 |
-
multiline=True,
|
| 1600 |
-
max_length=MAX_PROMPT_LENGTH_IMAGE_GEN,
|
| 1601 |
-
),
|
| 1602 |
-
"negative_prompt": model_field_to_node_input(
|
| 1603 |
-
IO.STRING,
|
| 1604 |
-
KlingImageGenerationsRequest,
|
| 1605 |
-
"negative_prompt",
|
| 1606 |
-
multiline=True,
|
| 1607 |
-
),
|
| 1608 |
-
"image_type": model_field_to_node_input(
|
| 1609 |
-
IO.COMBO,
|
| 1610 |
-
KlingImageGenerationsRequest,
|
| 1611 |
-
"image_reference",
|
| 1612 |
-
enum_type=KlingImageGenImageReferenceType,
|
| 1613 |
-
),
|
| 1614 |
-
"image_fidelity": model_field_to_node_input(
|
| 1615 |
-
IO.FLOAT,
|
| 1616 |
-
KlingImageGenerationsRequest,
|
| 1617 |
-
"image_fidelity",
|
| 1618 |
-
slider=True,
|
| 1619 |
-
step=0.01,
|
| 1620 |
-
),
|
| 1621 |
-
"human_fidelity": model_field_to_node_input(
|
| 1622 |
-
IO.FLOAT,
|
| 1623 |
-
KlingImageGenerationsRequest,
|
| 1624 |
-
"human_fidelity",
|
| 1625 |
-
slider=True,
|
| 1626 |
-
step=0.01,
|
| 1627 |
-
),
|
| 1628 |
-
"model_name": model_field_to_node_input(
|
| 1629 |
-
IO.COMBO,
|
| 1630 |
-
KlingImageGenerationsRequest,
|
| 1631 |
-
"model_name",
|
| 1632 |
-
enum_type=KlingImageGenModelName,
|
| 1633 |
-
),
|
| 1634 |
-
"aspect_ratio": model_field_to_node_input(
|
| 1635 |
-
IO.COMBO,
|
| 1636 |
-
KlingImageGenerationsRequest,
|
| 1637 |
-
"aspect_ratio",
|
| 1638 |
-
enum_type=KlingImageGenAspectRatio,
|
| 1639 |
-
),
|
| 1640 |
-
"n": model_field_to_node_input(
|
| 1641 |
-
IO.INT,
|
| 1642 |
-
KlingImageGenerationsRequest,
|
| 1643 |
-
"n",
|
| 1644 |
-
),
|
| 1645 |
-
},
|
| 1646 |
-
"optional": {
|
| 1647 |
-
"image": (IO.IMAGE, {}),
|
| 1648 |
-
},
|
| 1649 |
-
"hidden": {
|
| 1650 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 1651 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 1652 |
-
"unique_id": "UNIQUE_ID",
|
| 1653 |
-
},
|
| 1654 |
-
}
|
| 1655 |
-
|
| 1656 |
-
DESCRIPTION = "Kling Image Generation Node. Generate an image from a text prompt with an optional reference image."
|
| 1657 |
-
|
| 1658 |
-
def get_response(
|
| 1659 |
-
self,
|
| 1660 |
-
task_id: str,
|
| 1661 |
-
auth_kwargs: Optional[dict[str, str]],
|
| 1662 |
-
node_id: Optional[str] = None,
|
| 1663 |
-
) -> KlingImageGenerationsResponse:
|
| 1664 |
-
return poll_until_finished(
|
| 1665 |
-
auth_kwargs,
|
| 1666 |
-
ApiEndpoint(
|
| 1667 |
-
path=f"{PATH_IMAGE_GENERATIONS}/{task_id}",
|
| 1668 |
-
method=HttpMethod.GET,
|
| 1669 |
-
request_model=EmptyRequest,
|
| 1670 |
-
response_model=KlingImageGenerationsResponse,
|
| 1671 |
-
),
|
| 1672 |
-
result_url_extractor=get_images_urls_from_response,
|
| 1673 |
-
estimated_duration=AVERAGE_DURATION_IMAGE_GEN,
|
| 1674 |
-
node_id=node_id,
|
| 1675 |
-
)
|
| 1676 |
-
|
| 1677 |
-
def api_call(
|
| 1678 |
-
self,
|
| 1679 |
-
model_name: KlingImageGenModelName,
|
| 1680 |
-
prompt: str,
|
| 1681 |
-
negative_prompt: str,
|
| 1682 |
-
image_type: KlingImageGenImageReferenceType,
|
| 1683 |
-
image_fidelity: float,
|
| 1684 |
-
human_fidelity: float,
|
| 1685 |
-
n: int,
|
| 1686 |
-
aspect_ratio: KlingImageGenAspectRatio,
|
| 1687 |
-
image: Optional[torch.Tensor] = None,
|
| 1688 |
-
unique_id: Optional[str] = None,
|
| 1689 |
-
**kwargs,
|
| 1690 |
-
):
|
| 1691 |
-
self.validate_prompt(prompt, negative_prompt)
|
| 1692 |
-
|
| 1693 |
-
if image is not None:
|
| 1694 |
-
image = tensor_to_base64_string(image)
|
| 1695 |
-
|
| 1696 |
-
initial_operation = SynchronousOperation(
|
| 1697 |
-
endpoint=ApiEndpoint(
|
| 1698 |
-
path=PATH_IMAGE_GENERATIONS,
|
| 1699 |
-
method=HttpMethod.POST,
|
| 1700 |
-
request_model=KlingImageGenerationsRequest,
|
| 1701 |
-
response_model=KlingImageGenerationsResponse,
|
| 1702 |
-
),
|
| 1703 |
-
request=KlingImageGenerationsRequest(
|
| 1704 |
-
model_name=model_name,
|
| 1705 |
-
prompt=prompt,
|
| 1706 |
-
negative_prompt=negative_prompt,
|
| 1707 |
-
image=image,
|
| 1708 |
-
image_reference=image_type,
|
| 1709 |
-
image_fidelity=image_fidelity,
|
| 1710 |
-
human_fidelity=human_fidelity,
|
| 1711 |
-
n=n,
|
| 1712 |
-
aspect_ratio=aspect_ratio,
|
| 1713 |
-
),
|
| 1714 |
-
auth_kwargs=kwargs,
|
| 1715 |
-
)
|
| 1716 |
-
|
| 1717 |
-
task_creation_response = initial_operation.execute()
|
| 1718 |
-
validate_task_creation_response(task_creation_response)
|
| 1719 |
-
task_id = task_creation_response.data.task_id
|
| 1720 |
-
|
| 1721 |
-
final_response = self.get_response(
|
| 1722 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 1723 |
-
)
|
| 1724 |
-
validate_image_result_response(final_response)
|
| 1725 |
-
|
| 1726 |
-
images = get_images_from_response(final_response)
|
| 1727 |
-
return (image_result_to_node_output(images),)
|
| 1728 |
-
|
| 1729 |
-
|
| 1730 |
-
NODE_CLASS_MAPPINGS = {
|
| 1731 |
-
"KlingCameraControls": KlingCameraControls,
|
| 1732 |
-
"KlingTextToVideoNode": KlingTextToVideoNode,
|
| 1733 |
-
"KlingImage2VideoNode": KlingImage2VideoNode,
|
| 1734 |
-
"KlingCameraControlI2VNode": KlingCameraControlI2VNode,
|
| 1735 |
-
"KlingCameraControlT2VNode": KlingCameraControlT2VNode,
|
| 1736 |
-
"KlingStartEndFrameNode": KlingStartEndFrameNode,
|
| 1737 |
-
"KlingVideoExtendNode": KlingVideoExtendNode,
|
| 1738 |
-
"KlingLipSyncAudioToVideoNode": KlingLipSyncAudioToVideoNode,
|
| 1739 |
-
"KlingLipSyncTextToVideoNode": KlingLipSyncTextToVideoNode,
|
| 1740 |
-
"KlingVirtualTryOnNode": KlingVirtualTryOnNode,
|
| 1741 |
-
"KlingImageGenerationNode": KlingImageGenerationNode,
|
| 1742 |
-
"KlingSingleImageVideoEffectNode": KlingSingleImageVideoEffectNode,
|
| 1743 |
-
"KlingDualCharacterVideoEffectNode": KlingDualCharacterVideoEffectNode,
|
| 1744 |
-
}
|
| 1745 |
-
|
| 1746 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 1747 |
-
"KlingCameraControls": "Kling Camera Controls",
|
| 1748 |
-
"KlingTextToVideoNode": "Kling Text to Video",
|
| 1749 |
-
"KlingImage2VideoNode": "Kling Image to Video",
|
| 1750 |
-
"KlingCameraControlI2VNode": "Kling Image to Video (Camera Control)",
|
| 1751 |
-
"KlingCameraControlT2VNode": "Kling Text to Video (Camera Control)",
|
| 1752 |
-
"KlingStartEndFrameNode": "Kling Start-End Frame to Video",
|
| 1753 |
-
"KlingVideoExtendNode": "Kling Video Extend",
|
| 1754 |
-
"KlingLipSyncAudioToVideoNode": "Kling Lip Sync Video with Audio",
|
| 1755 |
-
"KlingLipSyncTextToVideoNode": "Kling Lip Sync Video with Text",
|
| 1756 |
-
"KlingVirtualTryOnNode": "Kling Virtual Try On",
|
| 1757 |
-
"KlingImageGenerationNode": "Kling Image Generation",
|
| 1758 |
-
"KlingSingleImageVideoEffectNode": "Kling Video Effects",
|
| 1759 |
-
"KlingDualCharacterVideoEffectNode": "Kling Dual Character Video Effects",
|
| 1760 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_luma.py
DELETED
|
@@ -1,737 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
from inspect import cleandoc
|
| 3 |
-
from typing import Optional
|
| 4 |
-
from comfy.comfy_types.node_typing import IO, ComfyNodeABC
|
| 5 |
-
from comfy_api.input_impl.video_types import VideoFromFile
|
| 6 |
-
from comfy_api_nodes.apis.luma_api import (
|
| 7 |
-
LumaImageModel,
|
| 8 |
-
LumaVideoModel,
|
| 9 |
-
LumaVideoOutputResolution,
|
| 10 |
-
LumaVideoModelOutputDuration,
|
| 11 |
-
LumaAspectRatio,
|
| 12 |
-
LumaState,
|
| 13 |
-
LumaImageGenerationRequest,
|
| 14 |
-
LumaGenerationRequest,
|
| 15 |
-
LumaGeneration,
|
| 16 |
-
LumaCharacterRef,
|
| 17 |
-
LumaModifyImageRef,
|
| 18 |
-
LumaImageIdentity,
|
| 19 |
-
LumaReference,
|
| 20 |
-
LumaReferenceChain,
|
| 21 |
-
LumaImageReference,
|
| 22 |
-
LumaKeyframes,
|
| 23 |
-
LumaConceptChain,
|
| 24 |
-
LumaIO,
|
| 25 |
-
get_luma_concepts,
|
| 26 |
-
)
|
| 27 |
-
from comfy_api_nodes.apis.client import (
|
| 28 |
-
ApiEndpoint,
|
| 29 |
-
HttpMethod,
|
| 30 |
-
SynchronousOperation,
|
| 31 |
-
PollingOperation,
|
| 32 |
-
EmptyRequest,
|
| 33 |
-
)
|
| 34 |
-
from comfy_api_nodes.apinode_utils import (
|
| 35 |
-
upload_images_to_comfyapi,
|
| 36 |
-
process_image_response,
|
| 37 |
-
validate_string,
|
| 38 |
-
)
|
| 39 |
-
from server import PromptServer
|
| 40 |
-
|
| 41 |
-
import requests
|
| 42 |
-
import torch
|
| 43 |
-
from io import BytesIO
|
| 44 |
-
|
| 45 |
-
LUMA_T2V_AVERAGE_DURATION = 105
|
| 46 |
-
LUMA_I2V_AVERAGE_DURATION = 100
|
| 47 |
-
|
| 48 |
-
def image_result_url_extractor(response: LumaGeneration):
|
| 49 |
-
return response.assets.image if hasattr(response, "assets") and hasattr(response.assets, "image") else None
|
| 50 |
-
|
| 51 |
-
def video_result_url_extractor(response: LumaGeneration):
|
| 52 |
-
return response.assets.video if hasattr(response, "assets") and hasattr(response.assets, "video") else None
|
| 53 |
-
|
| 54 |
-
class LumaReferenceNode(ComfyNodeABC):
|
| 55 |
-
"""
|
| 56 |
-
Holds an image and weight for use with Luma Generate Image node.
|
| 57 |
-
"""
|
| 58 |
-
|
| 59 |
-
RETURN_TYPES = (LumaIO.LUMA_REF,)
|
| 60 |
-
RETURN_NAMES = ("luma_ref",)
|
| 61 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 62 |
-
FUNCTION = "create_luma_reference"
|
| 63 |
-
CATEGORY = "api node/image/Luma"
|
| 64 |
-
|
| 65 |
-
@classmethod
|
| 66 |
-
def INPUT_TYPES(s):
|
| 67 |
-
return {
|
| 68 |
-
"required": {
|
| 69 |
-
"image": (
|
| 70 |
-
IO.IMAGE,
|
| 71 |
-
{
|
| 72 |
-
"tooltip": "Image to use as reference.",
|
| 73 |
-
},
|
| 74 |
-
),
|
| 75 |
-
"weight": (
|
| 76 |
-
IO.FLOAT,
|
| 77 |
-
{
|
| 78 |
-
"default": 1.0,
|
| 79 |
-
"min": 0.0,
|
| 80 |
-
"max": 1.0,
|
| 81 |
-
"step": 0.01,
|
| 82 |
-
"tooltip": "Weight of image reference.",
|
| 83 |
-
},
|
| 84 |
-
),
|
| 85 |
-
},
|
| 86 |
-
"optional": {"luma_ref": (LumaIO.LUMA_REF,)},
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
def create_luma_reference(
|
| 90 |
-
self, image: torch.Tensor, weight: float, luma_ref: LumaReferenceChain = None
|
| 91 |
-
):
|
| 92 |
-
if luma_ref is not None:
|
| 93 |
-
luma_ref = luma_ref.clone()
|
| 94 |
-
else:
|
| 95 |
-
luma_ref = LumaReferenceChain()
|
| 96 |
-
luma_ref.add(LumaReference(image=image, weight=round(weight, 2)))
|
| 97 |
-
return (luma_ref,)
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
class LumaConceptsNode(ComfyNodeABC):
|
| 101 |
-
"""
|
| 102 |
-
Holds one or more Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.
|
| 103 |
-
"""
|
| 104 |
-
|
| 105 |
-
RETURN_TYPES = (LumaIO.LUMA_CONCEPTS,)
|
| 106 |
-
RETURN_NAMES = ("luma_concepts",)
|
| 107 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 108 |
-
FUNCTION = "create_concepts"
|
| 109 |
-
CATEGORY = "api node/video/Luma"
|
| 110 |
-
|
| 111 |
-
@classmethod
|
| 112 |
-
def INPUT_TYPES(s):
|
| 113 |
-
return {
|
| 114 |
-
"required": {
|
| 115 |
-
"concept1": (get_luma_concepts(include_none=True),),
|
| 116 |
-
"concept2": (get_luma_concepts(include_none=True),),
|
| 117 |
-
"concept3": (get_luma_concepts(include_none=True),),
|
| 118 |
-
"concept4": (get_luma_concepts(include_none=True),),
|
| 119 |
-
},
|
| 120 |
-
"optional": {
|
| 121 |
-
"luma_concepts": (
|
| 122 |
-
LumaIO.LUMA_CONCEPTS,
|
| 123 |
-
{
|
| 124 |
-
"tooltip": "Optional Camera Concepts to add to the ones chosen here."
|
| 125 |
-
},
|
| 126 |
-
),
|
| 127 |
-
},
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
def create_concepts(
|
| 131 |
-
self,
|
| 132 |
-
concept1: str,
|
| 133 |
-
concept2: str,
|
| 134 |
-
concept3: str,
|
| 135 |
-
concept4: str,
|
| 136 |
-
luma_concepts: LumaConceptChain = None,
|
| 137 |
-
):
|
| 138 |
-
chain = LumaConceptChain(str_list=[concept1, concept2, concept3, concept4])
|
| 139 |
-
if luma_concepts is not None:
|
| 140 |
-
chain = luma_concepts.clone_and_merge(chain)
|
| 141 |
-
return (chain,)
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
class LumaImageGenerationNode(ComfyNodeABC):
|
| 145 |
-
"""
|
| 146 |
-
Generates images synchronously based on prompt and aspect ratio.
|
| 147 |
-
"""
|
| 148 |
-
|
| 149 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 150 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 151 |
-
FUNCTION = "api_call"
|
| 152 |
-
API_NODE = True
|
| 153 |
-
CATEGORY = "api node/image/Luma"
|
| 154 |
-
|
| 155 |
-
@classmethod
|
| 156 |
-
def INPUT_TYPES(s):
|
| 157 |
-
return {
|
| 158 |
-
"required": {
|
| 159 |
-
"prompt": (
|
| 160 |
-
IO.STRING,
|
| 161 |
-
{
|
| 162 |
-
"multiline": True,
|
| 163 |
-
"default": "",
|
| 164 |
-
"tooltip": "Prompt for the image generation",
|
| 165 |
-
},
|
| 166 |
-
),
|
| 167 |
-
"model": ([model.value for model in LumaImageModel],),
|
| 168 |
-
"aspect_ratio": (
|
| 169 |
-
[ratio.value for ratio in LumaAspectRatio],
|
| 170 |
-
{
|
| 171 |
-
"default": LumaAspectRatio.ratio_16_9,
|
| 172 |
-
},
|
| 173 |
-
),
|
| 174 |
-
"seed": (
|
| 175 |
-
IO.INT,
|
| 176 |
-
{
|
| 177 |
-
"default": 0,
|
| 178 |
-
"min": 0,
|
| 179 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 180 |
-
"control_after_generate": True,
|
| 181 |
-
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
|
| 182 |
-
},
|
| 183 |
-
),
|
| 184 |
-
"style_image_weight": (
|
| 185 |
-
IO.FLOAT,
|
| 186 |
-
{
|
| 187 |
-
"default": 1.0,
|
| 188 |
-
"min": 0.0,
|
| 189 |
-
"max": 1.0,
|
| 190 |
-
"step": 0.01,
|
| 191 |
-
"tooltip": "Weight of style image. Ignored if no style_image provided.",
|
| 192 |
-
},
|
| 193 |
-
),
|
| 194 |
-
},
|
| 195 |
-
"optional": {
|
| 196 |
-
"image_luma_ref": (
|
| 197 |
-
LumaIO.LUMA_REF,
|
| 198 |
-
{
|
| 199 |
-
"tooltip": "Luma Reference node connection to influence generation with input images; up to 4 images can be considered."
|
| 200 |
-
},
|
| 201 |
-
),
|
| 202 |
-
"style_image": (
|
| 203 |
-
IO.IMAGE,
|
| 204 |
-
{"tooltip": "Style reference image; only 1 image will be used."},
|
| 205 |
-
),
|
| 206 |
-
"character_image": (
|
| 207 |
-
IO.IMAGE,
|
| 208 |
-
{
|
| 209 |
-
"tooltip": "Character reference images; can be a batch of multiple, up to 4 images can be considered."
|
| 210 |
-
},
|
| 211 |
-
),
|
| 212 |
-
},
|
| 213 |
-
"hidden": {
|
| 214 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 215 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 216 |
-
"unique_id": "UNIQUE_ID",
|
| 217 |
-
},
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
def api_call(
|
| 221 |
-
self,
|
| 222 |
-
prompt: str,
|
| 223 |
-
model: str,
|
| 224 |
-
aspect_ratio: str,
|
| 225 |
-
seed,
|
| 226 |
-
style_image_weight: float,
|
| 227 |
-
image_luma_ref: LumaReferenceChain = None,
|
| 228 |
-
style_image: torch.Tensor = None,
|
| 229 |
-
character_image: torch.Tensor = None,
|
| 230 |
-
unique_id: str = None,
|
| 231 |
-
**kwargs,
|
| 232 |
-
):
|
| 233 |
-
validate_string(prompt, strip_whitespace=True, min_length=3)
|
| 234 |
-
# handle image_luma_ref
|
| 235 |
-
api_image_ref = None
|
| 236 |
-
if image_luma_ref is not None:
|
| 237 |
-
api_image_ref = self._convert_luma_refs(
|
| 238 |
-
image_luma_ref, max_refs=4, auth_kwargs=kwargs,
|
| 239 |
-
)
|
| 240 |
-
# handle style_luma_ref
|
| 241 |
-
api_style_ref = None
|
| 242 |
-
if style_image is not None:
|
| 243 |
-
api_style_ref = self._convert_style_image(
|
| 244 |
-
style_image, weight=style_image_weight, auth_kwargs=kwargs,
|
| 245 |
-
)
|
| 246 |
-
# handle character_ref images
|
| 247 |
-
character_ref = None
|
| 248 |
-
if character_image is not None:
|
| 249 |
-
download_urls = upload_images_to_comfyapi(
|
| 250 |
-
character_image, max_images=4, auth_kwargs=kwargs,
|
| 251 |
-
)
|
| 252 |
-
character_ref = LumaCharacterRef(
|
| 253 |
-
identity0=LumaImageIdentity(images=download_urls)
|
| 254 |
-
)
|
| 255 |
-
|
| 256 |
-
operation = SynchronousOperation(
|
| 257 |
-
endpoint=ApiEndpoint(
|
| 258 |
-
path="/proxy/luma/generations/image",
|
| 259 |
-
method=HttpMethod.POST,
|
| 260 |
-
request_model=LumaImageGenerationRequest,
|
| 261 |
-
response_model=LumaGeneration,
|
| 262 |
-
),
|
| 263 |
-
request=LumaImageGenerationRequest(
|
| 264 |
-
prompt=prompt,
|
| 265 |
-
model=model,
|
| 266 |
-
aspect_ratio=aspect_ratio,
|
| 267 |
-
image_ref=api_image_ref,
|
| 268 |
-
style_ref=api_style_ref,
|
| 269 |
-
character_ref=character_ref,
|
| 270 |
-
),
|
| 271 |
-
auth_kwargs=kwargs,
|
| 272 |
-
)
|
| 273 |
-
response_api: LumaGeneration = operation.execute()
|
| 274 |
-
|
| 275 |
-
operation = PollingOperation(
|
| 276 |
-
poll_endpoint=ApiEndpoint(
|
| 277 |
-
path=f"/proxy/luma/generations/{response_api.id}",
|
| 278 |
-
method=HttpMethod.GET,
|
| 279 |
-
request_model=EmptyRequest,
|
| 280 |
-
response_model=LumaGeneration,
|
| 281 |
-
),
|
| 282 |
-
completed_statuses=[LumaState.completed],
|
| 283 |
-
failed_statuses=[LumaState.failed],
|
| 284 |
-
status_extractor=lambda x: x.state,
|
| 285 |
-
result_url_extractor=image_result_url_extractor,
|
| 286 |
-
node_id=unique_id,
|
| 287 |
-
auth_kwargs=kwargs,
|
| 288 |
-
)
|
| 289 |
-
response_poll = operation.execute()
|
| 290 |
-
|
| 291 |
-
img_response = requests.get(response_poll.assets.image)
|
| 292 |
-
img = process_image_response(img_response)
|
| 293 |
-
return (img,)
|
| 294 |
-
|
| 295 |
-
def _convert_luma_refs(
|
| 296 |
-
self, luma_ref: LumaReferenceChain, max_refs: int, auth_kwargs: Optional[dict[str,str]] = None
|
| 297 |
-
):
|
| 298 |
-
luma_urls = []
|
| 299 |
-
ref_count = 0
|
| 300 |
-
for ref in luma_ref.refs:
|
| 301 |
-
download_urls = upload_images_to_comfyapi(
|
| 302 |
-
ref.image, max_images=1, auth_kwargs=auth_kwargs
|
| 303 |
-
)
|
| 304 |
-
luma_urls.append(download_urls[0])
|
| 305 |
-
ref_count += 1
|
| 306 |
-
if ref_count >= max_refs:
|
| 307 |
-
break
|
| 308 |
-
return luma_ref.create_api_model(download_urls=luma_urls, max_refs=max_refs)
|
| 309 |
-
|
| 310 |
-
def _convert_style_image(
|
| 311 |
-
self, style_image: torch.Tensor, weight: float, auth_kwargs: Optional[dict[str,str]] = None
|
| 312 |
-
):
|
| 313 |
-
chain = LumaReferenceChain(
|
| 314 |
-
first_ref=LumaReference(image=style_image, weight=weight)
|
| 315 |
-
)
|
| 316 |
-
return self._convert_luma_refs(chain, max_refs=1, auth_kwargs=auth_kwargs)
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
class LumaImageModifyNode(ComfyNodeABC):
|
| 320 |
-
"""
|
| 321 |
-
Modifies images synchronously based on prompt and aspect ratio.
|
| 322 |
-
"""
|
| 323 |
-
|
| 324 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 325 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 326 |
-
FUNCTION = "api_call"
|
| 327 |
-
API_NODE = True
|
| 328 |
-
CATEGORY = "api node/image/Luma"
|
| 329 |
-
|
| 330 |
-
@classmethod
|
| 331 |
-
def INPUT_TYPES(s):
|
| 332 |
-
return {
|
| 333 |
-
"required": {
|
| 334 |
-
"image": (IO.IMAGE,),
|
| 335 |
-
"prompt": (
|
| 336 |
-
IO.STRING,
|
| 337 |
-
{
|
| 338 |
-
"multiline": True,
|
| 339 |
-
"default": "",
|
| 340 |
-
"tooltip": "Prompt for the image generation",
|
| 341 |
-
},
|
| 342 |
-
),
|
| 343 |
-
"image_weight": (
|
| 344 |
-
IO.FLOAT,
|
| 345 |
-
{
|
| 346 |
-
"default": 0.1,
|
| 347 |
-
"min": 0.0,
|
| 348 |
-
"max": 0.98,
|
| 349 |
-
"step": 0.01,
|
| 350 |
-
"tooltip": "Weight of the image; the closer to 1.0, the less the image will be modified.",
|
| 351 |
-
},
|
| 352 |
-
),
|
| 353 |
-
"model": ([model.value for model in LumaImageModel],),
|
| 354 |
-
"seed": (
|
| 355 |
-
IO.INT,
|
| 356 |
-
{
|
| 357 |
-
"default": 0,
|
| 358 |
-
"min": 0,
|
| 359 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 360 |
-
"control_after_generate": True,
|
| 361 |
-
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
|
| 362 |
-
},
|
| 363 |
-
),
|
| 364 |
-
},
|
| 365 |
-
"optional": {},
|
| 366 |
-
"hidden": {
|
| 367 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 368 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 369 |
-
"unique_id": "UNIQUE_ID",
|
| 370 |
-
},
|
| 371 |
-
}
|
| 372 |
-
|
| 373 |
-
def api_call(
|
| 374 |
-
self,
|
| 375 |
-
prompt: str,
|
| 376 |
-
model: str,
|
| 377 |
-
image: torch.Tensor,
|
| 378 |
-
image_weight: float,
|
| 379 |
-
seed,
|
| 380 |
-
unique_id: str = None,
|
| 381 |
-
**kwargs,
|
| 382 |
-
):
|
| 383 |
-
# first, upload image
|
| 384 |
-
download_urls = upload_images_to_comfyapi(
|
| 385 |
-
image, max_images=1, auth_kwargs=kwargs,
|
| 386 |
-
)
|
| 387 |
-
image_url = download_urls[0]
|
| 388 |
-
# next, make Luma call with download url provided
|
| 389 |
-
operation = SynchronousOperation(
|
| 390 |
-
endpoint=ApiEndpoint(
|
| 391 |
-
path="/proxy/luma/generations/image",
|
| 392 |
-
method=HttpMethod.POST,
|
| 393 |
-
request_model=LumaImageGenerationRequest,
|
| 394 |
-
response_model=LumaGeneration,
|
| 395 |
-
),
|
| 396 |
-
request=LumaImageGenerationRequest(
|
| 397 |
-
prompt=prompt,
|
| 398 |
-
model=model,
|
| 399 |
-
modify_image_ref=LumaModifyImageRef(
|
| 400 |
-
url=image_url, weight=round(max(min(1.0-image_weight, 0.98), 0.0), 2)
|
| 401 |
-
),
|
| 402 |
-
),
|
| 403 |
-
auth_kwargs=kwargs,
|
| 404 |
-
)
|
| 405 |
-
response_api: LumaGeneration = operation.execute()
|
| 406 |
-
|
| 407 |
-
operation = PollingOperation(
|
| 408 |
-
poll_endpoint=ApiEndpoint(
|
| 409 |
-
path=f"/proxy/luma/generations/{response_api.id}",
|
| 410 |
-
method=HttpMethod.GET,
|
| 411 |
-
request_model=EmptyRequest,
|
| 412 |
-
response_model=LumaGeneration,
|
| 413 |
-
),
|
| 414 |
-
completed_statuses=[LumaState.completed],
|
| 415 |
-
failed_statuses=[LumaState.failed],
|
| 416 |
-
status_extractor=lambda x: x.state,
|
| 417 |
-
result_url_extractor=image_result_url_extractor,
|
| 418 |
-
node_id=unique_id,
|
| 419 |
-
auth_kwargs=kwargs,
|
| 420 |
-
)
|
| 421 |
-
response_poll = operation.execute()
|
| 422 |
-
|
| 423 |
-
img_response = requests.get(response_poll.assets.image)
|
| 424 |
-
img = process_image_response(img_response)
|
| 425 |
-
return (img,)
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
class LumaTextToVideoGenerationNode(ComfyNodeABC):
|
| 429 |
-
"""
|
| 430 |
-
Generates videos synchronously based on prompt and output_size.
|
| 431 |
-
"""
|
| 432 |
-
|
| 433 |
-
RETURN_TYPES = (IO.VIDEO,)
|
| 434 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 435 |
-
FUNCTION = "api_call"
|
| 436 |
-
API_NODE = True
|
| 437 |
-
CATEGORY = "api node/video/Luma"
|
| 438 |
-
|
| 439 |
-
@classmethod
|
| 440 |
-
def INPUT_TYPES(s):
|
| 441 |
-
return {
|
| 442 |
-
"required": {
|
| 443 |
-
"prompt": (
|
| 444 |
-
IO.STRING,
|
| 445 |
-
{
|
| 446 |
-
"multiline": True,
|
| 447 |
-
"default": "",
|
| 448 |
-
"tooltip": "Prompt for the video generation",
|
| 449 |
-
},
|
| 450 |
-
),
|
| 451 |
-
"model": ([model.value for model in LumaVideoModel],),
|
| 452 |
-
"aspect_ratio": (
|
| 453 |
-
[ratio.value for ratio in LumaAspectRatio],
|
| 454 |
-
{
|
| 455 |
-
"default": LumaAspectRatio.ratio_16_9,
|
| 456 |
-
},
|
| 457 |
-
),
|
| 458 |
-
"resolution": (
|
| 459 |
-
[resolution.value for resolution in LumaVideoOutputResolution],
|
| 460 |
-
{
|
| 461 |
-
"default": LumaVideoOutputResolution.res_540p,
|
| 462 |
-
},
|
| 463 |
-
),
|
| 464 |
-
"duration": ([dur.value for dur in LumaVideoModelOutputDuration],),
|
| 465 |
-
"loop": (
|
| 466 |
-
IO.BOOLEAN,
|
| 467 |
-
{
|
| 468 |
-
"default": False,
|
| 469 |
-
},
|
| 470 |
-
),
|
| 471 |
-
"seed": (
|
| 472 |
-
IO.INT,
|
| 473 |
-
{
|
| 474 |
-
"default": 0,
|
| 475 |
-
"min": 0,
|
| 476 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 477 |
-
"control_after_generate": True,
|
| 478 |
-
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
|
| 479 |
-
},
|
| 480 |
-
),
|
| 481 |
-
},
|
| 482 |
-
"optional": {
|
| 483 |
-
"luma_concepts": (
|
| 484 |
-
LumaIO.LUMA_CONCEPTS,
|
| 485 |
-
{
|
| 486 |
-
"tooltip": "Optional Camera Concepts to dictate camera motion via the Luma Concepts node."
|
| 487 |
-
},
|
| 488 |
-
),
|
| 489 |
-
},
|
| 490 |
-
"hidden": {
|
| 491 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 492 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 493 |
-
"unique_id": "UNIQUE_ID",
|
| 494 |
-
},
|
| 495 |
-
}
|
| 496 |
-
|
| 497 |
-
def api_call(
|
| 498 |
-
self,
|
| 499 |
-
prompt: str,
|
| 500 |
-
model: str,
|
| 501 |
-
aspect_ratio: str,
|
| 502 |
-
resolution: str,
|
| 503 |
-
duration: str,
|
| 504 |
-
loop: bool,
|
| 505 |
-
seed,
|
| 506 |
-
luma_concepts: LumaConceptChain = None,
|
| 507 |
-
unique_id: str = None,
|
| 508 |
-
**kwargs,
|
| 509 |
-
):
|
| 510 |
-
validate_string(prompt, strip_whitespace=False, min_length=3)
|
| 511 |
-
duration = duration if model != LumaVideoModel.ray_1_6 else None
|
| 512 |
-
resolution = resolution if model != LumaVideoModel.ray_1_6 else None
|
| 513 |
-
|
| 514 |
-
operation = SynchronousOperation(
|
| 515 |
-
endpoint=ApiEndpoint(
|
| 516 |
-
path="/proxy/luma/generations",
|
| 517 |
-
method=HttpMethod.POST,
|
| 518 |
-
request_model=LumaGenerationRequest,
|
| 519 |
-
response_model=LumaGeneration,
|
| 520 |
-
),
|
| 521 |
-
request=LumaGenerationRequest(
|
| 522 |
-
prompt=prompt,
|
| 523 |
-
model=model,
|
| 524 |
-
resolution=resolution,
|
| 525 |
-
aspect_ratio=aspect_ratio,
|
| 526 |
-
duration=duration,
|
| 527 |
-
loop=loop,
|
| 528 |
-
concepts=luma_concepts.create_api_model() if luma_concepts else None,
|
| 529 |
-
),
|
| 530 |
-
auth_kwargs=kwargs,
|
| 531 |
-
)
|
| 532 |
-
response_api: LumaGeneration = operation.execute()
|
| 533 |
-
|
| 534 |
-
if unique_id:
|
| 535 |
-
PromptServer.instance.send_progress_text(f"Luma video generation started: {response_api.id}", unique_id)
|
| 536 |
-
|
| 537 |
-
operation = PollingOperation(
|
| 538 |
-
poll_endpoint=ApiEndpoint(
|
| 539 |
-
path=f"/proxy/luma/generations/{response_api.id}",
|
| 540 |
-
method=HttpMethod.GET,
|
| 541 |
-
request_model=EmptyRequest,
|
| 542 |
-
response_model=LumaGeneration,
|
| 543 |
-
),
|
| 544 |
-
completed_statuses=[LumaState.completed],
|
| 545 |
-
failed_statuses=[LumaState.failed],
|
| 546 |
-
status_extractor=lambda x: x.state,
|
| 547 |
-
result_url_extractor=video_result_url_extractor,
|
| 548 |
-
node_id=unique_id,
|
| 549 |
-
estimated_duration=LUMA_T2V_AVERAGE_DURATION,
|
| 550 |
-
auth_kwargs=kwargs,
|
| 551 |
-
)
|
| 552 |
-
response_poll = operation.execute()
|
| 553 |
-
|
| 554 |
-
vid_response = requests.get(response_poll.assets.video)
|
| 555 |
-
return (VideoFromFile(BytesIO(vid_response.content)),)
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
class LumaImageToVideoGenerationNode(ComfyNodeABC):
|
| 559 |
-
"""
|
| 560 |
-
Generates videos synchronously based on prompt, input images, and output_size.
|
| 561 |
-
"""
|
| 562 |
-
|
| 563 |
-
RETURN_TYPES = (IO.VIDEO,)
|
| 564 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 565 |
-
FUNCTION = "api_call"
|
| 566 |
-
API_NODE = True
|
| 567 |
-
CATEGORY = "api node/video/Luma"
|
| 568 |
-
|
| 569 |
-
@classmethod
|
| 570 |
-
def INPUT_TYPES(s):
|
| 571 |
-
return {
|
| 572 |
-
"required": {
|
| 573 |
-
"prompt": (
|
| 574 |
-
IO.STRING,
|
| 575 |
-
{
|
| 576 |
-
"multiline": True,
|
| 577 |
-
"default": "",
|
| 578 |
-
"tooltip": "Prompt for the video generation",
|
| 579 |
-
},
|
| 580 |
-
),
|
| 581 |
-
"model": ([model.value for model in LumaVideoModel],),
|
| 582 |
-
# "aspect_ratio": ([ratio.value for ratio in LumaAspectRatio], {
|
| 583 |
-
# "default": LumaAspectRatio.ratio_16_9,
|
| 584 |
-
# }),
|
| 585 |
-
"resolution": (
|
| 586 |
-
[resolution.value for resolution in LumaVideoOutputResolution],
|
| 587 |
-
{
|
| 588 |
-
"default": LumaVideoOutputResolution.res_540p,
|
| 589 |
-
},
|
| 590 |
-
),
|
| 591 |
-
"duration": ([dur.value for dur in LumaVideoModelOutputDuration],),
|
| 592 |
-
"loop": (
|
| 593 |
-
IO.BOOLEAN,
|
| 594 |
-
{
|
| 595 |
-
"default": False,
|
| 596 |
-
},
|
| 597 |
-
),
|
| 598 |
-
"seed": (
|
| 599 |
-
IO.INT,
|
| 600 |
-
{
|
| 601 |
-
"default": 0,
|
| 602 |
-
"min": 0,
|
| 603 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 604 |
-
"control_after_generate": True,
|
| 605 |
-
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
|
| 606 |
-
},
|
| 607 |
-
),
|
| 608 |
-
},
|
| 609 |
-
"optional": {
|
| 610 |
-
"first_image": (
|
| 611 |
-
IO.IMAGE,
|
| 612 |
-
{"tooltip": "First frame of generated video."},
|
| 613 |
-
),
|
| 614 |
-
"last_image": (IO.IMAGE, {"tooltip": "Last frame of generated video."}),
|
| 615 |
-
"luma_concepts": (
|
| 616 |
-
LumaIO.LUMA_CONCEPTS,
|
| 617 |
-
{
|
| 618 |
-
"tooltip": "Optional Camera Concepts to dictate camera motion via the Luma Concepts node."
|
| 619 |
-
},
|
| 620 |
-
),
|
| 621 |
-
},
|
| 622 |
-
"hidden": {
|
| 623 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 624 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 625 |
-
"unique_id": "UNIQUE_ID",
|
| 626 |
-
},
|
| 627 |
-
}
|
| 628 |
-
|
| 629 |
-
def api_call(
|
| 630 |
-
self,
|
| 631 |
-
prompt: str,
|
| 632 |
-
model: str,
|
| 633 |
-
resolution: str,
|
| 634 |
-
duration: str,
|
| 635 |
-
loop: bool,
|
| 636 |
-
seed,
|
| 637 |
-
first_image: torch.Tensor = None,
|
| 638 |
-
last_image: torch.Tensor = None,
|
| 639 |
-
luma_concepts: LumaConceptChain = None,
|
| 640 |
-
unique_id: str = None,
|
| 641 |
-
**kwargs,
|
| 642 |
-
):
|
| 643 |
-
if first_image is None and last_image is None:
|
| 644 |
-
raise Exception(
|
| 645 |
-
"At least one of first_image and last_image requires an input."
|
| 646 |
-
)
|
| 647 |
-
keyframes = self._convert_to_keyframes(first_image, last_image, auth_kwargs=kwargs)
|
| 648 |
-
duration = duration if model != LumaVideoModel.ray_1_6 else None
|
| 649 |
-
resolution = resolution if model != LumaVideoModel.ray_1_6 else None
|
| 650 |
-
|
| 651 |
-
operation = SynchronousOperation(
|
| 652 |
-
endpoint=ApiEndpoint(
|
| 653 |
-
path="/proxy/luma/generations",
|
| 654 |
-
method=HttpMethod.POST,
|
| 655 |
-
request_model=LumaGenerationRequest,
|
| 656 |
-
response_model=LumaGeneration,
|
| 657 |
-
),
|
| 658 |
-
request=LumaGenerationRequest(
|
| 659 |
-
prompt=prompt,
|
| 660 |
-
model=model,
|
| 661 |
-
aspect_ratio=LumaAspectRatio.ratio_16_9, # ignored, but still needed by the API for some reason
|
| 662 |
-
resolution=resolution,
|
| 663 |
-
duration=duration,
|
| 664 |
-
loop=loop,
|
| 665 |
-
keyframes=keyframes,
|
| 666 |
-
concepts=luma_concepts.create_api_model() if luma_concepts else None,
|
| 667 |
-
),
|
| 668 |
-
auth_kwargs=kwargs,
|
| 669 |
-
)
|
| 670 |
-
response_api: LumaGeneration = operation.execute()
|
| 671 |
-
|
| 672 |
-
if unique_id:
|
| 673 |
-
PromptServer.instance.send_progress_text(f"Luma video generation started: {response_api.id}", unique_id)
|
| 674 |
-
|
| 675 |
-
operation = PollingOperation(
|
| 676 |
-
poll_endpoint=ApiEndpoint(
|
| 677 |
-
path=f"/proxy/luma/generations/{response_api.id}",
|
| 678 |
-
method=HttpMethod.GET,
|
| 679 |
-
request_model=EmptyRequest,
|
| 680 |
-
response_model=LumaGeneration,
|
| 681 |
-
),
|
| 682 |
-
completed_statuses=[LumaState.completed],
|
| 683 |
-
failed_statuses=[LumaState.failed],
|
| 684 |
-
status_extractor=lambda x: x.state,
|
| 685 |
-
result_url_extractor=video_result_url_extractor,
|
| 686 |
-
node_id=unique_id,
|
| 687 |
-
estimated_duration=LUMA_I2V_AVERAGE_DURATION,
|
| 688 |
-
auth_kwargs=kwargs,
|
| 689 |
-
)
|
| 690 |
-
response_poll = operation.execute()
|
| 691 |
-
|
| 692 |
-
vid_response = requests.get(response_poll.assets.video)
|
| 693 |
-
return (VideoFromFile(BytesIO(vid_response.content)),)
|
| 694 |
-
|
| 695 |
-
def _convert_to_keyframes(
|
| 696 |
-
self,
|
| 697 |
-
first_image: torch.Tensor = None,
|
| 698 |
-
last_image: torch.Tensor = None,
|
| 699 |
-
auth_kwargs: Optional[dict[str,str]] = None,
|
| 700 |
-
):
|
| 701 |
-
if first_image is None and last_image is None:
|
| 702 |
-
return None
|
| 703 |
-
frame0 = None
|
| 704 |
-
frame1 = None
|
| 705 |
-
if first_image is not None:
|
| 706 |
-
download_urls = upload_images_to_comfyapi(
|
| 707 |
-
first_image, max_images=1, auth_kwargs=auth_kwargs,
|
| 708 |
-
)
|
| 709 |
-
frame0 = LumaImageReference(type="image", url=download_urls[0])
|
| 710 |
-
if last_image is not None:
|
| 711 |
-
download_urls = upload_images_to_comfyapi(
|
| 712 |
-
last_image, max_images=1, auth_kwargs=auth_kwargs,
|
| 713 |
-
)
|
| 714 |
-
frame1 = LumaImageReference(type="image", url=download_urls[0])
|
| 715 |
-
return LumaKeyframes(frame0=frame0, frame1=frame1)
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
# A dictionary that contains all nodes you want to export with their names
|
| 719 |
-
# NOTE: names should be globally unique
|
| 720 |
-
NODE_CLASS_MAPPINGS = {
|
| 721 |
-
"LumaImageNode": LumaImageGenerationNode,
|
| 722 |
-
"LumaImageModifyNode": LumaImageModifyNode,
|
| 723 |
-
"LumaVideoNode": LumaTextToVideoGenerationNode,
|
| 724 |
-
"LumaImageToVideoNode": LumaImageToVideoGenerationNode,
|
| 725 |
-
"LumaReferenceNode": LumaReferenceNode,
|
| 726 |
-
"LumaConceptsNode": LumaConceptsNode,
|
| 727 |
-
}
|
| 728 |
-
|
| 729 |
-
# A dictionary that contains the friendly/humanly readable titles for the nodes
|
| 730 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 731 |
-
"LumaImageNode": "Luma Text to Image",
|
| 732 |
-
"LumaImageModifyNode": "Luma Image to Image",
|
| 733 |
-
"LumaVideoNode": "Luma Text to Video",
|
| 734 |
-
"LumaImageToVideoNode": "Luma Image to Video",
|
| 735 |
-
"LumaReferenceNode": "Luma Reference",
|
| 736 |
-
"LumaConceptsNode": "Luma Concepts",
|
| 737 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_minimax.py
DELETED
|
@@ -1,332 +0,0 @@
|
|
| 1 |
-
from typing import Union
|
| 2 |
-
import logging
|
| 3 |
-
import torch
|
| 4 |
-
|
| 5 |
-
from comfy.comfy_types.node_typing import IO
|
| 6 |
-
from comfy_api.input_impl.video_types import VideoFromFile
|
| 7 |
-
from comfy_api_nodes.apis import (
|
| 8 |
-
MinimaxVideoGenerationRequest,
|
| 9 |
-
MinimaxVideoGenerationResponse,
|
| 10 |
-
MinimaxFileRetrieveResponse,
|
| 11 |
-
MinimaxTaskResultResponse,
|
| 12 |
-
SubjectReferenceItem,
|
| 13 |
-
Model
|
| 14 |
-
)
|
| 15 |
-
from comfy_api_nodes.apis.client import (
|
| 16 |
-
ApiEndpoint,
|
| 17 |
-
HttpMethod,
|
| 18 |
-
SynchronousOperation,
|
| 19 |
-
PollingOperation,
|
| 20 |
-
EmptyRequest,
|
| 21 |
-
)
|
| 22 |
-
from comfy_api_nodes.apinode_utils import (
|
| 23 |
-
download_url_to_bytesio,
|
| 24 |
-
upload_images_to_comfyapi,
|
| 25 |
-
validate_string,
|
| 26 |
-
)
|
| 27 |
-
from server import PromptServer
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
I2V_AVERAGE_DURATION = 114
|
| 31 |
-
T2V_AVERAGE_DURATION = 234
|
| 32 |
-
|
| 33 |
-
class MinimaxTextToVideoNode:
|
| 34 |
-
"""
|
| 35 |
-
Generates videos synchronously based on a prompt, and optional parameters using MiniMax's API.
|
| 36 |
-
"""
|
| 37 |
-
|
| 38 |
-
AVERAGE_DURATION = T2V_AVERAGE_DURATION
|
| 39 |
-
|
| 40 |
-
@classmethod
|
| 41 |
-
def INPUT_TYPES(s):
|
| 42 |
-
return {
|
| 43 |
-
"required": {
|
| 44 |
-
"prompt_text": (
|
| 45 |
-
"STRING",
|
| 46 |
-
{
|
| 47 |
-
"multiline": True,
|
| 48 |
-
"default": "",
|
| 49 |
-
"tooltip": "Text prompt to guide the video generation",
|
| 50 |
-
},
|
| 51 |
-
),
|
| 52 |
-
"model": (
|
| 53 |
-
[
|
| 54 |
-
"T2V-01",
|
| 55 |
-
"T2V-01-Director",
|
| 56 |
-
],
|
| 57 |
-
{
|
| 58 |
-
"default": "T2V-01",
|
| 59 |
-
"tooltip": "Model to use for video generation",
|
| 60 |
-
},
|
| 61 |
-
),
|
| 62 |
-
},
|
| 63 |
-
"optional": {
|
| 64 |
-
"seed": (
|
| 65 |
-
IO.INT,
|
| 66 |
-
{
|
| 67 |
-
"default": 0,
|
| 68 |
-
"min": 0,
|
| 69 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 70 |
-
"control_after_generate": True,
|
| 71 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 72 |
-
},
|
| 73 |
-
),
|
| 74 |
-
},
|
| 75 |
-
"hidden": {
|
| 76 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 77 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 78 |
-
"unique_id": "UNIQUE_ID",
|
| 79 |
-
},
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
RETURN_TYPES = ("VIDEO",)
|
| 83 |
-
DESCRIPTION = "Generates videos from prompts using MiniMax's API"
|
| 84 |
-
FUNCTION = "generate_video"
|
| 85 |
-
CATEGORY = "api node/video/MiniMax"
|
| 86 |
-
API_NODE = True
|
| 87 |
-
OUTPUT_NODE = True
|
| 88 |
-
|
| 89 |
-
def generate_video(
|
| 90 |
-
self,
|
| 91 |
-
prompt_text,
|
| 92 |
-
seed=0,
|
| 93 |
-
model="T2V-01",
|
| 94 |
-
image: torch.Tensor=None, # used for ImageToVideo
|
| 95 |
-
subject: torch.Tensor=None, # used for SubjectToVideo
|
| 96 |
-
unique_id: Union[str, None]=None,
|
| 97 |
-
**kwargs,
|
| 98 |
-
):
|
| 99 |
-
'''
|
| 100 |
-
Function used between MiniMax nodes - supports T2V, I2V, and S2V, based on provided arguments.
|
| 101 |
-
'''
|
| 102 |
-
if image is None:
|
| 103 |
-
validate_string(prompt_text, field_name="prompt_text")
|
| 104 |
-
# upload image, if passed in
|
| 105 |
-
image_url = None
|
| 106 |
-
if image is not None:
|
| 107 |
-
image_url = upload_images_to_comfyapi(image, max_images=1, auth_kwargs=kwargs)[0]
|
| 108 |
-
|
| 109 |
-
# TODO: figure out how to deal with subject properly, API returns invalid params when using S2V-01 model
|
| 110 |
-
subject_reference = None
|
| 111 |
-
if subject is not None:
|
| 112 |
-
subject_url = upload_images_to_comfyapi(subject, max_images=1, auth_kwargs=kwargs)[0]
|
| 113 |
-
subject_reference = [SubjectReferenceItem(image=subject_url)]
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
video_generate_operation = SynchronousOperation(
|
| 117 |
-
endpoint=ApiEndpoint(
|
| 118 |
-
path="/proxy/minimax/video_generation",
|
| 119 |
-
method=HttpMethod.POST,
|
| 120 |
-
request_model=MinimaxVideoGenerationRequest,
|
| 121 |
-
response_model=MinimaxVideoGenerationResponse,
|
| 122 |
-
),
|
| 123 |
-
request=MinimaxVideoGenerationRequest(
|
| 124 |
-
model=Model(model),
|
| 125 |
-
prompt=prompt_text,
|
| 126 |
-
callback_url=None,
|
| 127 |
-
first_frame_image=image_url,
|
| 128 |
-
subject_reference=subject_reference,
|
| 129 |
-
prompt_optimizer=None,
|
| 130 |
-
),
|
| 131 |
-
auth_kwargs=kwargs,
|
| 132 |
-
)
|
| 133 |
-
response = video_generate_operation.execute()
|
| 134 |
-
|
| 135 |
-
task_id = response.task_id
|
| 136 |
-
if not task_id:
|
| 137 |
-
raise Exception(f"MiniMax generation failed: {response.base_resp}")
|
| 138 |
-
|
| 139 |
-
video_generate_operation = PollingOperation(
|
| 140 |
-
poll_endpoint=ApiEndpoint(
|
| 141 |
-
path="/proxy/minimax/query/video_generation",
|
| 142 |
-
method=HttpMethod.GET,
|
| 143 |
-
request_model=EmptyRequest,
|
| 144 |
-
response_model=MinimaxTaskResultResponse,
|
| 145 |
-
query_params={"task_id": task_id},
|
| 146 |
-
),
|
| 147 |
-
completed_statuses=["Success"],
|
| 148 |
-
failed_statuses=["Fail"],
|
| 149 |
-
status_extractor=lambda x: x.status.value,
|
| 150 |
-
estimated_duration=self.AVERAGE_DURATION,
|
| 151 |
-
node_id=unique_id,
|
| 152 |
-
auth_kwargs=kwargs,
|
| 153 |
-
)
|
| 154 |
-
task_result = video_generate_operation.execute()
|
| 155 |
-
|
| 156 |
-
file_id = task_result.file_id
|
| 157 |
-
if file_id is None:
|
| 158 |
-
raise Exception("Request was not successful. Missing file ID.")
|
| 159 |
-
file_retrieve_operation = SynchronousOperation(
|
| 160 |
-
endpoint=ApiEndpoint(
|
| 161 |
-
path="/proxy/minimax/files/retrieve",
|
| 162 |
-
method=HttpMethod.GET,
|
| 163 |
-
request_model=EmptyRequest,
|
| 164 |
-
response_model=MinimaxFileRetrieveResponse,
|
| 165 |
-
query_params={"file_id": int(file_id)},
|
| 166 |
-
),
|
| 167 |
-
request=EmptyRequest(),
|
| 168 |
-
auth_kwargs=kwargs,
|
| 169 |
-
)
|
| 170 |
-
file_result = file_retrieve_operation.execute()
|
| 171 |
-
|
| 172 |
-
file_url = file_result.file.download_url
|
| 173 |
-
if file_url is None:
|
| 174 |
-
raise Exception(
|
| 175 |
-
f"No video was found in the response. Full response: {file_result.model_dump()}"
|
| 176 |
-
)
|
| 177 |
-
logging.info(f"Generated video URL: {file_url}")
|
| 178 |
-
if unique_id:
|
| 179 |
-
if hasattr(file_result.file, "backup_download_url"):
|
| 180 |
-
message = f"Result URL: {file_url}\nBackup URL: {file_result.file.backup_download_url}"
|
| 181 |
-
else:
|
| 182 |
-
message = f"Result URL: {file_url}"
|
| 183 |
-
PromptServer.instance.send_progress_text(message, unique_id)
|
| 184 |
-
|
| 185 |
-
video_io = download_url_to_bytesio(file_url)
|
| 186 |
-
if video_io is None:
|
| 187 |
-
error_msg = f"Failed to download video from {file_url}"
|
| 188 |
-
logging.error(error_msg)
|
| 189 |
-
raise Exception(error_msg)
|
| 190 |
-
return (VideoFromFile(video_io),)
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
class MinimaxImageToVideoNode(MinimaxTextToVideoNode):
|
| 194 |
-
"""
|
| 195 |
-
Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.
|
| 196 |
-
"""
|
| 197 |
-
|
| 198 |
-
AVERAGE_DURATION = I2V_AVERAGE_DURATION
|
| 199 |
-
|
| 200 |
-
@classmethod
|
| 201 |
-
def INPUT_TYPES(s):
|
| 202 |
-
return {
|
| 203 |
-
"required": {
|
| 204 |
-
"image": (
|
| 205 |
-
IO.IMAGE,
|
| 206 |
-
{
|
| 207 |
-
"tooltip": "Image to use as first frame of video generation"
|
| 208 |
-
},
|
| 209 |
-
),
|
| 210 |
-
"prompt_text": (
|
| 211 |
-
"STRING",
|
| 212 |
-
{
|
| 213 |
-
"multiline": True,
|
| 214 |
-
"default": "",
|
| 215 |
-
"tooltip": "Text prompt to guide the video generation",
|
| 216 |
-
},
|
| 217 |
-
),
|
| 218 |
-
"model": (
|
| 219 |
-
[
|
| 220 |
-
"I2V-01-Director",
|
| 221 |
-
"I2V-01",
|
| 222 |
-
"I2V-01-live",
|
| 223 |
-
],
|
| 224 |
-
{
|
| 225 |
-
"default": "I2V-01",
|
| 226 |
-
"tooltip": "Model to use for video generation",
|
| 227 |
-
},
|
| 228 |
-
),
|
| 229 |
-
},
|
| 230 |
-
"optional": {
|
| 231 |
-
"seed": (
|
| 232 |
-
IO.INT,
|
| 233 |
-
{
|
| 234 |
-
"default": 0,
|
| 235 |
-
"min": 0,
|
| 236 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 237 |
-
"control_after_generate": True,
|
| 238 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 239 |
-
},
|
| 240 |
-
),
|
| 241 |
-
},
|
| 242 |
-
"hidden": {
|
| 243 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 244 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 245 |
-
"unique_id": "UNIQUE_ID",
|
| 246 |
-
},
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
RETURN_TYPES = ("VIDEO",)
|
| 250 |
-
DESCRIPTION = "Generates videos from an image and prompts using MiniMax's API"
|
| 251 |
-
FUNCTION = "generate_video"
|
| 252 |
-
CATEGORY = "api node/video/MiniMax"
|
| 253 |
-
API_NODE = True
|
| 254 |
-
OUTPUT_NODE = True
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
class MinimaxSubjectToVideoNode(MinimaxTextToVideoNode):
|
| 258 |
-
"""
|
| 259 |
-
Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.
|
| 260 |
-
"""
|
| 261 |
-
|
| 262 |
-
AVERAGE_DURATION = T2V_AVERAGE_DURATION
|
| 263 |
-
|
| 264 |
-
@classmethod
|
| 265 |
-
def INPUT_TYPES(s):
|
| 266 |
-
return {
|
| 267 |
-
"required": {
|
| 268 |
-
"subject": (
|
| 269 |
-
IO.IMAGE,
|
| 270 |
-
{
|
| 271 |
-
"tooltip": "Image of subject to reference video generation"
|
| 272 |
-
},
|
| 273 |
-
),
|
| 274 |
-
"prompt_text": (
|
| 275 |
-
"STRING",
|
| 276 |
-
{
|
| 277 |
-
"multiline": True,
|
| 278 |
-
"default": "",
|
| 279 |
-
"tooltip": "Text prompt to guide the video generation",
|
| 280 |
-
},
|
| 281 |
-
),
|
| 282 |
-
"model": (
|
| 283 |
-
[
|
| 284 |
-
"S2V-01",
|
| 285 |
-
],
|
| 286 |
-
{
|
| 287 |
-
"default": "S2V-01",
|
| 288 |
-
"tooltip": "Model to use for video generation",
|
| 289 |
-
},
|
| 290 |
-
),
|
| 291 |
-
},
|
| 292 |
-
"optional": {
|
| 293 |
-
"seed": (
|
| 294 |
-
IO.INT,
|
| 295 |
-
{
|
| 296 |
-
"default": 0,
|
| 297 |
-
"min": 0,
|
| 298 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 299 |
-
"control_after_generate": True,
|
| 300 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 301 |
-
},
|
| 302 |
-
),
|
| 303 |
-
},
|
| 304 |
-
"hidden": {
|
| 305 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 306 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 307 |
-
"unique_id": "UNIQUE_ID",
|
| 308 |
-
},
|
| 309 |
-
}
|
| 310 |
-
|
| 311 |
-
RETURN_TYPES = ("VIDEO",)
|
| 312 |
-
DESCRIPTION = "Generates videos from an image and prompts using MiniMax's API"
|
| 313 |
-
FUNCTION = "generate_video"
|
| 314 |
-
CATEGORY = "api node/video/MiniMax"
|
| 315 |
-
API_NODE = True
|
| 316 |
-
OUTPUT_NODE = True
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
# A dictionary that contains all nodes you want to export with their names
|
| 320 |
-
# NOTE: names should be globally unique
|
| 321 |
-
NODE_CLASS_MAPPINGS = {
|
| 322 |
-
"MinimaxTextToVideoNode": MinimaxTextToVideoNode,
|
| 323 |
-
"MinimaxImageToVideoNode": MinimaxImageToVideoNode,
|
| 324 |
-
# "MinimaxSubjectToVideoNode": MinimaxSubjectToVideoNode,
|
| 325 |
-
}
|
| 326 |
-
|
| 327 |
-
# A dictionary that contains the friendly/humanly readable titles for the nodes
|
| 328 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 329 |
-
"MinimaxTextToVideoNode": "MiniMax Text to Video",
|
| 330 |
-
"MinimaxImageToVideoNode": "MiniMax Image to Video",
|
| 331 |
-
"MinimaxSubjectToVideoNode": "MiniMax Subject to Video",
|
| 332 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_moonvalley.py
DELETED
|
@@ -1,639 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
from typing import Any, Callable, Optional, TypeVar
|
| 3 |
-
import random
|
| 4 |
-
import torch
|
| 5 |
-
from comfy_api_nodes.util.validation_utils import get_image_dimensions, validate_image_dimensions, validate_video_dimensions
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
from comfy_api_nodes.apis import (
|
| 9 |
-
MoonvalleyTextToVideoRequest,
|
| 10 |
-
MoonvalleyTextToVideoInferenceParams,
|
| 11 |
-
MoonvalleyVideoToVideoInferenceParams,
|
| 12 |
-
MoonvalleyVideoToVideoRequest,
|
| 13 |
-
MoonvalleyPromptResponse
|
| 14 |
-
)
|
| 15 |
-
from comfy_api_nodes.apis.client import (
|
| 16 |
-
ApiEndpoint,
|
| 17 |
-
HttpMethod,
|
| 18 |
-
SynchronousOperation,
|
| 19 |
-
PollingOperation,
|
| 20 |
-
EmptyRequest,
|
| 21 |
-
)
|
| 22 |
-
from comfy_api_nodes.apinode_utils import (
|
| 23 |
-
download_url_to_video_output,
|
| 24 |
-
upload_images_to_comfyapi,
|
| 25 |
-
upload_video_to_comfyapi,
|
| 26 |
-
)
|
| 27 |
-
from comfy_api_nodes.mapper_utils import model_field_to_node_input
|
| 28 |
-
|
| 29 |
-
from comfy_api.input.video_types import VideoInput
|
| 30 |
-
from comfy.comfy_types.node_typing import IO
|
| 31 |
-
from comfy_api.input_impl import VideoFromFile
|
| 32 |
-
import av
|
| 33 |
-
import io
|
| 34 |
-
|
| 35 |
-
API_UPLOADS_ENDPOINT = "/proxy/moonvalley/uploads"
|
| 36 |
-
API_PROMPTS_ENDPOINT = "/proxy/moonvalley/prompts"
|
| 37 |
-
API_VIDEO2VIDEO_ENDPOINT = "/proxy/moonvalley/prompts/video-to-video"
|
| 38 |
-
API_TXT2VIDEO_ENDPOINT = "/proxy/moonvalley/prompts/text-to-video"
|
| 39 |
-
API_IMG2VIDEO_ENDPOINT = "/proxy/moonvalley/prompts/image-to-video"
|
| 40 |
-
|
| 41 |
-
MIN_WIDTH = 300
|
| 42 |
-
MIN_HEIGHT = 300
|
| 43 |
-
|
| 44 |
-
MAX_WIDTH = 10000
|
| 45 |
-
MAX_HEIGHT = 10000
|
| 46 |
-
|
| 47 |
-
MIN_VID_WIDTH = 300
|
| 48 |
-
MIN_VID_HEIGHT = 300
|
| 49 |
-
|
| 50 |
-
MAX_VID_WIDTH = 10000
|
| 51 |
-
MAX_VID_HEIGHT = 10000
|
| 52 |
-
|
| 53 |
-
MAX_VIDEO_SIZE = 1024 * 1024 * 1024 # 1 GB max for in-memory video processing
|
| 54 |
-
|
| 55 |
-
MOONVALLEY_MAREY_MAX_PROMPT_LENGTH = 5000
|
| 56 |
-
R = TypeVar("R")
|
| 57 |
-
class MoonvalleyApiError(Exception):
|
| 58 |
-
"""Base exception for Moonvalley API errors."""
|
| 59 |
-
pass
|
| 60 |
-
|
| 61 |
-
def is_valid_task_creation_response(response: MoonvalleyPromptResponse) -> bool:
|
| 62 |
-
"""Verifies that the initial response contains a task ID."""
|
| 63 |
-
return bool(response.id)
|
| 64 |
-
|
| 65 |
-
def validate_task_creation_response(response) -> None:
|
| 66 |
-
if not is_valid_task_creation_response(response):
|
| 67 |
-
error_msg = f"Moonvalley Marey API: Initial request failed. Code: {response.code}, Message: {response.message}, Data: {response}"
|
| 68 |
-
logging.error(error_msg)
|
| 69 |
-
raise MoonvalleyApiError(error_msg)
|
| 70 |
-
|
| 71 |
-
def get_video_from_response(response):
|
| 72 |
-
video = response.output_url
|
| 73 |
-
logging.info(
|
| 74 |
-
"Moonvalley Marey API: Task %s succeeded. Video URL: %s", response.id, video
|
| 75 |
-
)
|
| 76 |
-
return video
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def get_video_url_from_response(response) -> Optional[str]:
|
| 80 |
-
"""Returns the first video url from the Moonvalley video generation task result.
|
| 81 |
-
Will not raise an error if the response is not valid.
|
| 82 |
-
"""
|
| 83 |
-
if response:
|
| 84 |
-
return str(get_video_from_response(response))
|
| 85 |
-
else:
|
| 86 |
-
return None
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def poll_until_finished(
|
| 90 |
-
auth_kwargs: dict[str, str],
|
| 91 |
-
api_endpoint: ApiEndpoint[Any, R],
|
| 92 |
-
result_url_extractor: Optional[Callable[[R], str]] = None,
|
| 93 |
-
node_id: Optional[str] = None,
|
| 94 |
-
) -> R:
|
| 95 |
-
"""Polls the Moonvalley API endpoint until the task reaches a terminal state, then returns the response."""
|
| 96 |
-
return PollingOperation(
|
| 97 |
-
poll_endpoint=api_endpoint,
|
| 98 |
-
completed_statuses=[
|
| 99 |
-
"completed",
|
| 100 |
-
],
|
| 101 |
-
max_poll_attempts=240, # 64 minutes with 16s interval
|
| 102 |
-
poll_interval=16.0,
|
| 103 |
-
failed_statuses=["error"],
|
| 104 |
-
status_extractor=lambda response: (
|
| 105 |
-
response.status
|
| 106 |
-
if response and response.status
|
| 107 |
-
else None
|
| 108 |
-
),
|
| 109 |
-
auth_kwargs=auth_kwargs,
|
| 110 |
-
result_url_extractor=result_url_extractor,
|
| 111 |
-
node_id=node_id,
|
| 112 |
-
).execute()
|
| 113 |
-
|
| 114 |
-
def validate_prompts(prompt:str, negative_prompt: str, max_length=MOONVALLEY_MAREY_MAX_PROMPT_LENGTH):
|
| 115 |
-
"""Verifies that the prompt isn't empty and that neither prompt is too long."""
|
| 116 |
-
if not prompt:
|
| 117 |
-
raise ValueError("Positive prompt is empty")
|
| 118 |
-
if len(prompt) > max_length:
|
| 119 |
-
raise ValueError(f"Positive prompt is too long: {len(prompt)} characters")
|
| 120 |
-
if negative_prompt and len(negative_prompt) > max_length:
|
| 121 |
-
raise ValueError(
|
| 122 |
-
f"Negative prompt is too long: {len(negative_prompt)} characters"
|
| 123 |
-
)
|
| 124 |
-
return True
|
| 125 |
-
|
| 126 |
-
def validate_input_media(width, height, with_frame_conditioning, num_frames_in=None):
|
| 127 |
-
# inference validation
|
| 128 |
-
# T = num_frames
|
| 129 |
-
# in all cases, the following must be true: T divisible by 16 and H,W by 8. in addition...
|
| 130 |
-
# with image conditioning: H*W must be divisible by 8192
|
| 131 |
-
# without image conditioning: T divisible by 32
|
| 132 |
-
if num_frames_in and not num_frames_in % 16 == 0 :
|
| 133 |
-
return False, (
|
| 134 |
-
"The input video total frame count must be divisible by 16!"
|
| 135 |
-
)
|
| 136 |
-
|
| 137 |
-
if height % 8 != 0 or width % 8 != 0:
|
| 138 |
-
return False, (
|
| 139 |
-
f"Height ({height}) and width ({width}) must be " "divisible by 8"
|
| 140 |
-
)
|
| 141 |
-
|
| 142 |
-
if with_frame_conditioning:
|
| 143 |
-
if (height * width) % 8192 != 0:
|
| 144 |
-
return False, (
|
| 145 |
-
f"Height * width ({height * width}) must be "
|
| 146 |
-
"divisible by 8192 for frame conditioning"
|
| 147 |
-
)
|
| 148 |
-
else:
|
| 149 |
-
if num_frames_in and not num_frames_in % 32 == 0 :
|
| 150 |
-
return False, (
|
| 151 |
-
"The input video total frame count must be divisible by 32!"
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def validate_input_image(image: torch.Tensor, with_frame_conditioning: bool=False) -> None:
|
| 156 |
-
"""
|
| 157 |
-
Validates the input image adheres to the expectations of the API:
|
| 158 |
-
- The image resolution should not be less than 300*300px
|
| 159 |
-
- The aspect ratio of the image should be between 1:2.5 ~ 2.5:1
|
| 160 |
-
|
| 161 |
-
"""
|
| 162 |
-
height, width = get_image_dimensions(image)
|
| 163 |
-
validate_input_media(width, height, with_frame_conditioning )
|
| 164 |
-
validate_image_dimensions(image, min_width=300, min_height=300, max_height=MAX_HEIGHT, max_width=MAX_WIDTH)
|
| 165 |
-
|
| 166 |
-
def validate_input_video(video: VideoInput, num_frames_out: int, with_frame_conditioning: bool=False):
|
| 167 |
-
try:
|
| 168 |
-
width, height = video.get_dimensions()
|
| 169 |
-
except Exception as e:
|
| 170 |
-
logging.error("Error getting dimensions of video: %s", e)
|
| 171 |
-
raise ValueError(f"Cannot get video dimensions: {e}") from e
|
| 172 |
-
|
| 173 |
-
validate_input_media(width, height, with_frame_conditioning)
|
| 174 |
-
validate_video_dimensions(video, min_width=MIN_VID_WIDTH, min_height=MIN_VID_HEIGHT, max_width=MAX_VID_WIDTH, max_height=MAX_VID_HEIGHT)
|
| 175 |
-
|
| 176 |
-
trimmed_video = validate_input_video_length(video, num_frames_out)
|
| 177 |
-
return trimmed_video
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
def validate_input_video_length(video: VideoInput, num_frames: int):
|
| 181 |
-
|
| 182 |
-
if video.get_duration() > 60:
|
| 183 |
-
raise MoonvalleyApiError("Input Video lenth should be less than 1min. Please trim.")
|
| 184 |
-
|
| 185 |
-
if num_frames == 128:
|
| 186 |
-
if video.get_duration() < 5:
|
| 187 |
-
raise MoonvalleyApiError("Input Video length is less than 5s. Please use a video longer than or equal to 5s.")
|
| 188 |
-
if video.get_duration() > 5:
|
| 189 |
-
# trim video to 5s
|
| 190 |
-
video = trim_video(video, 5)
|
| 191 |
-
if num_frames == 256:
|
| 192 |
-
if video.get_duration() < 10:
|
| 193 |
-
raise MoonvalleyApiError("Input Video length is less than 10s. Please use a video longer than or equal to 10s.")
|
| 194 |
-
if video.get_duration() > 10:
|
| 195 |
-
# trim video to 10s
|
| 196 |
-
video = trim_video(video, 10)
|
| 197 |
-
return video
|
| 198 |
-
|
| 199 |
-
def trim_video(video: VideoInput, duration_sec: float) -> VideoInput:
|
| 200 |
-
"""
|
| 201 |
-
Returns a new VideoInput object trimmed from the beginning to the specified duration,
|
| 202 |
-
using av to avoid loading entire video into memory.
|
| 203 |
-
|
| 204 |
-
Args:
|
| 205 |
-
video: Input video to trim
|
| 206 |
-
duration_sec: Duration in seconds to keep from the beginning
|
| 207 |
-
|
| 208 |
-
Returns:
|
| 209 |
-
VideoFromFile object that owns the output buffer
|
| 210 |
-
"""
|
| 211 |
-
output_buffer = io.BytesIO()
|
| 212 |
-
|
| 213 |
-
input_container = None
|
| 214 |
-
output_container = None
|
| 215 |
-
|
| 216 |
-
try:
|
| 217 |
-
# Get the stream source - this avoids loading entire video into memory
|
| 218 |
-
# when the source is already a file path
|
| 219 |
-
input_source = video.get_stream_source()
|
| 220 |
-
|
| 221 |
-
# Open containers
|
| 222 |
-
input_container = av.open(input_source, mode='r')
|
| 223 |
-
output_container = av.open(output_buffer, mode='w', format='mp4')
|
| 224 |
-
|
| 225 |
-
# Set up output streams for re-encoding
|
| 226 |
-
video_stream = None
|
| 227 |
-
audio_stream = None
|
| 228 |
-
|
| 229 |
-
for stream in input_container.streams:
|
| 230 |
-
logging.info(f"Found stream: type={stream.type}, class={type(stream)}")
|
| 231 |
-
if isinstance(stream, av.VideoStream):
|
| 232 |
-
# Create output video stream with same parameters
|
| 233 |
-
video_stream = output_container.add_stream('h264', rate=stream.average_rate)
|
| 234 |
-
video_stream.width = stream.width
|
| 235 |
-
video_stream.height = stream.height
|
| 236 |
-
video_stream.pix_fmt = 'yuv420p'
|
| 237 |
-
logging.info(f"Added video stream: {stream.width}x{stream.height} @ {stream.average_rate}fps")
|
| 238 |
-
elif isinstance(stream, av.AudioStream):
|
| 239 |
-
# Create output audio stream with same parameters
|
| 240 |
-
audio_stream = output_container.add_stream('aac', rate=stream.sample_rate)
|
| 241 |
-
audio_stream.sample_rate = stream.sample_rate
|
| 242 |
-
audio_stream.layout = stream.layout
|
| 243 |
-
logging.info(f"Added audio stream: {stream.sample_rate}Hz, {stream.channels} channels")
|
| 244 |
-
|
| 245 |
-
# Calculate target frame count that's divisible by 32
|
| 246 |
-
fps = input_container.streams.video[0].average_rate
|
| 247 |
-
estimated_frames = int(duration_sec * fps)
|
| 248 |
-
target_frames = (estimated_frames // 32) * 32 # Round down to nearest multiple of 32
|
| 249 |
-
|
| 250 |
-
if target_frames == 0:
|
| 251 |
-
raise ValueError("Video too short: need at least 32 frames for Moonvalley")
|
| 252 |
-
|
| 253 |
-
frame_count = 0
|
| 254 |
-
audio_frame_count = 0
|
| 255 |
-
|
| 256 |
-
# Decode and re-encode video frames
|
| 257 |
-
if video_stream:
|
| 258 |
-
for frame in input_container.decode(video=0):
|
| 259 |
-
if frame_count >= target_frames:
|
| 260 |
-
break
|
| 261 |
-
|
| 262 |
-
# Re-encode frame
|
| 263 |
-
for packet in video_stream.encode(frame):
|
| 264 |
-
output_container.mux(packet)
|
| 265 |
-
frame_count += 1
|
| 266 |
-
|
| 267 |
-
# Flush encoder
|
| 268 |
-
for packet in video_stream.encode():
|
| 269 |
-
output_container.mux(packet)
|
| 270 |
-
|
| 271 |
-
logging.info(f"Encoded {frame_count} video frames (target: {target_frames})")
|
| 272 |
-
|
| 273 |
-
# Decode and re-encode audio frames
|
| 274 |
-
if audio_stream:
|
| 275 |
-
input_container.seek(0) # Reset to beginning for audio
|
| 276 |
-
for frame in input_container.decode(audio=0):
|
| 277 |
-
if frame.time >= duration_sec:
|
| 278 |
-
break
|
| 279 |
-
|
| 280 |
-
# Re-encode frame
|
| 281 |
-
for packet in audio_stream.encode(frame):
|
| 282 |
-
output_container.mux(packet)
|
| 283 |
-
audio_frame_count += 1
|
| 284 |
-
|
| 285 |
-
# Flush encoder
|
| 286 |
-
for packet in audio_stream.encode():
|
| 287 |
-
output_container.mux(packet)
|
| 288 |
-
|
| 289 |
-
logging.info(f"Encoded {audio_frame_count} audio frames")
|
| 290 |
-
|
| 291 |
-
# Close containers
|
| 292 |
-
output_container.close()
|
| 293 |
-
input_container.close()
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
# Return as VideoFromFile using the buffer
|
| 297 |
-
output_buffer.seek(0)
|
| 298 |
-
return VideoFromFile(output_buffer)
|
| 299 |
-
|
| 300 |
-
except Exception as e:
|
| 301 |
-
# Clean up on error
|
| 302 |
-
if input_container is not None:
|
| 303 |
-
input_container.close()
|
| 304 |
-
if output_container is not None:
|
| 305 |
-
output_container.close()
|
| 306 |
-
raise RuntimeError(f"Failed to trim video: {str(e)}") from e
|
| 307 |
-
|
| 308 |
-
# --- BaseMoonvalleyVideoNode ---
|
| 309 |
-
class BaseMoonvalleyVideoNode:
|
| 310 |
-
def parseWidthHeightFromRes(self, resolution: str):
|
| 311 |
-
# Accepts a string like "16:9 (1920 x 1080)" and returns width, height as a dict
|
| 312 |
-
res_map = {
|
| 313 |
-
"16:9 (1920 x 1080)": {"width": 1920, "height": 1080},
|
| 314 |
-
"9:16 (1080 x 1920)": {"width": 1080, "height": 1920},
|
| 315 |
-
"1:1 (1152 x 1152)": {"width": 1152, "height": 1152},
|
| 316 |
-
"4:3 (1440 x 1080)": {"width": 1440, "height": 1080},
|
| 317 |
-
"3:4 (1080 x 1440)": {"width": 1080, "height": 1440},
|
| 318 |
-
"21:9 (2560 x 1080)": {"width": 2560, "height": 1080},
|
| 319 |
-
}
|
| 320 |
-
if resolution in res_map:
|
| 321 |
-
return res_map[resolution]
|
| 322 |
-
else:
|
| 323 |
-
# Default to 1920x1080 if unknown
|
| 324 |
-
return {"width": 1920, "height": 1080}
|
| 325 |
-
|
| 326 |
-
def parseControlParameter(self, value):
|
| 327 |
-
control_map = {
|
| 328 |
-
"Motion Transfer": "motion_control",
|
| 329 |
-
"Canny": "canny_control",
|
| 330 |
-
"Pose Transfer": "pose_control",
|
| 331 |
-
"Depth": "depth_control"
|
| 332 |
-
}
|
| 333 |
-
if value in control_map:
|
| 334 |
-
return control_map[value]
|
| 335 |
-
else:
|
| 336 |
-
return control_map["Motion Transfer"]
|
| 337 |
-
|
| 338 |
-
def get_response(
|
| 339 |
-
self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
|
| 340 |
-
) -> MoonvalleyPromptResponse:
|
| 341 |
-
return poll_until_finished(
|
| 342 |
-
auth_kwargs,
|
| 343 |
-
ApiEndpoint(
|
| 344 |
-
path=f"{API_PROMPTS_ENDPOINT}/{task_id}",
|
| 345 |
-
method=HttpMethod.GET,
|
| 346 |
-
request_model=EmptyRequest,
|
| 347 |
-
response_model=MoonvalleyPromptResponse,
|
| 348 |
-
),
|
| 349 |
-
result_url_extractor=get_video_url_from_response,
|
| 350 |
-
node_id=node_id,
|
| 351 |
-
)
|
| 352 |
-
|
| 353 |
-
@classmethod
|
| 354 |
-
def INPUT_TYPES(cls):
|
| 355 |
-
return {
|
| 356 |
-
"required": {
|
| 357 |
-
"prompt": model_field_to_node_input(
|
| 358 |
-
IO.STRING, MoonvalleyTextToVideoRequest, "prompt_text",
|
| 359 |
-
multiline=True
|
| 360 |
-
),
|
| 361 |
-
"negative_prompt": model_field_to_node_input(
|
| 362 |
-
IO.STRING,
|
| 363 |
-
MoonvalleyTextToVideoInferenceParams,
|
| 364 |
-
"negative_prompt",
|
| 365 |
-
multiline=True,
|
| 366 |
-
default="gopro, bright, contrast, static, overexposed, bright, vignette, artifacts, still, noise, texture, scanlines, videogame, 360 camera, VR, transition, flare, saturation, distorted, warped, wide angle, contrast, saturated, vibrant, glowing, cross dissolve, texture, videogame, saturation, cheesy, ugly hands, mutated hands, mutant, disfigured, extra fingers, blown out, horrible, blurry, worst quality, bad, transition, dissolve, cross-dissolve, melt, fade in, fade out, wobbly, weird, low quality, plastic, stock footage, video camera, boring, static",
|
| 367 |
-
),
|
| 368 |
-
|
| 369 |
-
"resolution": (IO.COMBO, {
|
| 370 |
-
"options": ["16:9 (1920 x 1080)",
|
| 371 |
-
"9:16 (1080 x 1920)",
|
| 372 |
-
"1:1 (1152 x 1152)",
|
| 373 |
-
"4:3 (1440 x 1080)",
|
| 374 |
-
"3:4 (1080 x 1440)",
|
| 375 |
-
"21:9 (2560 x 1080)"],
|
| 376 |
-
"default": "16:9 (1920 x 1080)",
|
| 377 |
-
"tooltip": "Resolution of the output video",
|
| 378 |
-
}),
|
| 379 |
-
# "length": (IO.COMBO,{"options":['5s','10s'], "default": '5s'}),
|
| 380 |
-
"prompt_adherence": model_field_to_node_input(IO.FLOAT,MoonvalleyTextToVideoInferenceParams,"guidance_scale",default=7.0, step=1, min=1, max=20),
|
| 381 |
-
"seed": model_field_to_node_input(IO.INT,MoonvalleyTextToVideoInferenceParams, "seed", default=random.randint(0, 2**32 - 1), min=0, max=4294967295, step=1, display="number", tooltip="Random seed value", control_after_generate=True),
|
| 382 |
-
"steps": model_field_to_node_input(IO.INT, MoonvalleyTextToVideoInferenceParams, "steps", default=100, min=1, max=100),
|
| 383 |
-
},
|
| 384 |
-
"hidden": {
|
| 385 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 386 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 387 |
-
"unique_id": "UNIQUE_ID",
|
| 388 |
-
},
|
| 389 |
-
"optional": {
|
| 390 |
-
"image": model_field_to_node_input(
|
| 391 |
-
IO.IMAGE,
|
| 392 |
-
MoonvalleyTextToVideoRequest,
|
| 393 |
-
"image_url",
|
| 394 |
-
tooltip="The reference image used to generate the video",
|
| 395 |
-
),
|
| 396 |
-
}
|
| 397 |
-
}
|
| 398 |
-
|
| 399 |
-
RETURN_TYPES = ("STRING",)
|
| 400 |
-
FUNCTION = "generate"
|
| 401 |
-
CATEGORY = "api node/video/Moonvalley Marey"
|
| 402 |
-
API_NODE = True
|
| 403 |
-
|
| 404 |
-
def generate(self, **kwargs):
|
| 405 |
-
return None
|
| 406 |
-
|
| 407 |
-
# --- MoonvalleyImg2VideoNode ---
|
| 408 |
-
class MoonvalleyImg2VideoNode(BaseMoonvalleyVideoNode):
|
| 409 |
-
|
| 410 |
-
@classmethod
|
| 411 |
-
def INPUT_TYPES(cls):
|
| 412 |
-
return super().INPUT_TYPES()
|
| 413 |
-
|
| 414 |
-
RETURN_TYPES = ("VIDEO",)
|
| 415 |
-
RETURN_NAMES = ("video",)
|
| 416 |
-
DESCRIPTION = "Moonvalley Marey Image to Video Node"
|
| 417 |
-
|
| 418 |
-
def generate(self, prompt, negative_prompt, unique_id: Optional[str] = None, **kwargs):
|
| 419 |
-
image = kwargs.get("image", None)
|
| 420 |
-
if (image is None):
|
| 421 |
-
raise MoonvalleyApiError("image is required")
|
| 422 |
-
total_frames = get_total_frames_from_length()
|
| 423 |
-
|
| 424 |
-
validate_input_image(image,True)
|
| 425 |
-
validate_prompts(prompt, negative_prompt, MOONVALLEY_MAREY_MAX_PROMPT_LENGTH)
|
| 426 |
-
width_height = self.parseWidthHeightFromRes(kwargs.get("resolution"))
|
| 427 |
-
|
| 428 |
-
inference_params=MoonvalleyTextToVideoInferenceParams(
|
| 429 |
-
negative_prompt=negative_prompt,
|
| 430 |
-
steps=kwargs.get("steps"),
|
| 431 |
-
seed=kwargs.get("seed"),
|
| 432 |
-
guidance_scale=kwargs.get("prompt_adherence"),
|
| 433 |
-
num_frames=total_frames,
|
| 434 |
-
width=width_height.get("width"),
|
| 435 |
-
height=width_height.get("height"),
|
| 436 |
-
use_negative_prompts=True
|
| 437 |
-
)
|
| 438 |
-
"""Upload image to comfy backend to have a URL available for further processing"""
|
| 439 |
-
# Get MIME type from tensor - assuming PNG format for image tensors
|
| 440 |
-
mime_type = "image/png"
|
| 441 |
-
|
| 442 |
-
image_url = upload_images_to_comfyapi(image, max_images=1, auth_kwargs=kwargs, mime_type=mime_type)[0]
|
| 443 |
-
|
| 444 |
-
request = MoonvalleyTextToVideoRequest(
|
| 445 |
-
image_url=image_url,
|
| 446 |
-
prompt_text=prompt,
|
| 447 |
-
inference_params=inference_params
|
| 448 |
-
)
|
| 449 |
-
initial_operation = SynchronousOperation(
|
| 450 |
-
endpoint=ApiEndpoint(path=API_IMG2VIDEO_ENDPOINT,
|
| 451 |
-
method=HttpMethod.POST,
|
| 452 |
-
request_model=MoonvalleyTextToVideoRequest,
|
| 453 |
-
response_model=MoonvalleyPromptResponse
|
| 454 |
-
),
|
| 455 |
-
request=request,
|
| 456 |
-
auth_kwargs=kwargs,
|
| 457 |
-
)
|
| 458 |
-
task_creation_response = initial_operation.execute()
|
| 459 |
-
validate_task_creation_response(task_creation_response)
|
| 460 |
-
task_id = task_creation_response.id
|
| 461 |
-
|
| 462 |
-
final_response = self.get_response(
|
| 463 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 464 |
-
)
|
| 465 |
-
video = download_url_to_video_output(final_response.output_url)
|
| 466 |
-
return (video, )
|
| 467 |
-
|
| 468 |
-
# --- MoonvalleyVid2VidNode ---
|
| 469 |
-
class MoonvalleyVideo2VideoNode(BaseMoonvalleyVideoNode):
|
| 470 |
-
def __init__(self):
|
| 471 |
-
super().__init__()
|
| 472 |
-
|
| 473 |
-
@classmethod
|
| 474 |
-
def INPUT_TYPES(cls):
|
| 475 |
-
input_types = super().INPUT_TYPES()
|
| 476 |
-
for param in ["resolution", "image"]:
|
| 477 |
-
if param in input_types["required"]:
|
| 478 |
-
del input_types["required"][param]
|
| 479 |
-
if param in input_types["optional"]:
|
| 480 |
-
del input_types["optional"][param]
|
| 481 |
-
input_types["optional"] = {
|
| 482 |
-
"video": (IO.VIDEO, {"default": "", "multiline": False, "tooltip": "The reference video used to generate the output video. Input a 5s video for 128 frames and a 10s video for 256 frames. Longer videos will be trimmed automatically."}),
|
| 483 |
-
"control_type": (
|
| 484 |
-
["Motion Transfer", "Pose Transfer"],
|
| 485 |
-
{"default": "Motion Transfer"},
|
| 486 |
-
),
|
| 487 |
-
"motion_intensity": (
|
| 488 |
-
"INT",
|
| 489 |
-
{
|
| 490 |
-
"default": 100,
|
| 491 |
-
"step": 1,
|
| 492 |
-
"min": 0,
|
| 493 |
-
"max": 100,
|
| 494 |
-
"tooltip": "Only used if control_type is 'Motion Transfer'",
|
| 495 |
-
},
|
| 496 |
-
)
|
| 497 |
-
}
|
| 498 |
-
|
| 499 |
-
return input_types
|
| 500 |
-
|
| 501 |
-
RETURN_TYPES = ("VIDEO",)
|
| 502 |
-
RETURN_NAMES = ("video",)
|
| 503 |
-
|
| 504 |
-
def generate(self, prompt, negative_prompt, unique_id: Optional[str] = None, **kwargs):
|
| 505 |
-
video = kwargs.get("video")
|
| 506 |
-
num_frames = get_total_frames_from_length()
|
| 507 |
-
|
| 508 |
-
if not video :
|
| 509 |
-
raise MoonvalleyApiError("video is required")
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
"""Validate video input"""
|
| 513 |
-
video_url=""
|
| 514 |
-
if video:
|
| 515 |
-
validated_video = validate_input_video(video, num_frames, False)
|
| 516 |
-
video_url = upload_video_to_comfyapi(validated_video, auth_kwargs=kwargs)
|
| 517 |
-
|
| 518 |
-
control_type = kwargs.get("control_type")
|
| 519 |
-
motion_intensity = kwargs.get("motion_intensity")
|
| 520 |
-
|
| 521 |
-
"""Validate prompts and inference input"""
|
| 522 |
-
validate_prompts(prompt, negative_prompt)
|
| 523 |
-
inference_params=MoonvalleyVideoToVideoInferenceParams(
|
| 524 |
-
negative_prompt=negative_prompt,
|
| 525 |
-
steps=kwargs.get("steps"),
|
| 526 |
-
seed=kwargs.get("seed"),
|
| 527 |
-
guidance_scale=kwargs.get("prompt_adherence"),
|
| 528 |
-
control_params={'motion_intensity': motion_intensity}
|
| 529 |
-
)
|
| 530 |
-
|
| 531 |
-
control = self.parseControlParameter(control_type)
|
| 532 |
-
|
| 533 |
-
request = MoonvalleyVideoToVideoRequest(
|
| 534 |
-
control_type=control,
|
| 535 |
-
video_url=video_url,
|
| 536 |
-
prompt_text=prompt,
|
| 537 |
-
inference_params=inference_params
|
| 538 |
-
)
|
| 539 |
-
|
| 540 |
-
initial_operation = SynchronousOperation(
|
| 541 |
-
endpoint=ApiEndpoint(path=API_VIDEO2VIDEO_ENDPOINT,
|
| 542 |
-
method=HttpMethod.POST,
|
| 543 |
-
request_model=MoonvalleyVideoToVideoRequest,
|
| 544 |
-
response_model=MoonvalleyPromptResponse
|
| 545 |
-
),
|
| 546 |
-
request=request,
|
| 547 |
-
auth_kwargs=kwargs,
|
| 548 |
-
)
|
| 549 |
-
task_creation_response = initial_operation.execute()
|
| 550 |
-
validate_task_creation_response(task_creation_response)
|
| 551 |
-
task_id = task_creation_response.id
|
| 552 |
-
|
| 553 |
-
final_response = self.get_response(
|
| 554 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 555 |
-
)
|
| 556 |
-
|
| 557 |
-
video = download_url_to_video_output(final_response.output_url)
|
| 558 |
-
|
| 559 |
-
return (video, )
|
| 560 |
-
|
| 561 |
-
# --- MoonvalleyTxt2VideoNode ---
|
| 562 |
-
class MoonvalleyTxt2VideoNode(BaseMoonvalleyVideoNode):
|
| 563 |
-
def __init__(self):
|
| 564 |
-
super().__init__()
|
| 565 |
-
|
| 566 |
-
RETURN_TYPES = ("VIDEO",)
|
| 567 |
-
RETURN_NAMES = ("video",)
|
| 568 |
-
|
| 569 |
-
@classmethod
|
| 570 |
-
def INPUT_TYPES(cls):
|
| 571 |
-
input_types = super().INPUT_TYPES()
|
| 572 |
-
# Remove image-specific parameters
|
| 573 |
-
for param in ["image"]:
|
| 574 |
-
if param in input_types["optional"]:
|
| 575 |
-
del input_types["optional"][param]
|
| 576 |
-
return input_types
|
| 577 |
-
|
| 578 |
-
def generate(self, prompt, negative_prompt, unique_id: Optional[str] = None, **kwargs):
|
| 579 |
-
validate_prompts(prompt, negative_prompt, MOONVALLEY_MAREY_MAX_PROMPT_LENGTH)
|
| 580 |
-
width_height = self.parseWidthHeightFromRes(kwargs.get("resolution"))
|
| 581 |
-
num_frames = get_total_frames_from_length()
|
| 582 |
-
|
| 583 |
-
inference_params=MoonvalleyTextToVideoInferenceParams(
|
| 584 |
-
negative_prompt=negative_prompt,
|
| 585 |
-
steps=kwargs.get("steps"),
|
| 586 |
-
seed=kwargs.get("seed"),
|
| 587 |
-
guidance_scale=kwargs.get("prompt_adherence"),
|
| 588 |
-
num_frames=num_frames,
|
| 589 |
-
width=width_height.get("width"),
|
| 590 |
-
height=width_height.get("height"),
|
| 591 |
-
)
|
| 592 |
-
request = MoonvalleyTextToVideoRequest(
|
| 593 |
-
prompt_text=prompt,
|
| 594 |
-
inference_params=inference_params
|
| 595 |
-
)
|
| 596 |
-
|
| 597 |
-
initial_operation = SynchronousOperation(
|
| 598 |
-
endpoint=ApiEndpoint(path=API_TXT2VIDEO_ENDPOINT,
|
| 599 |
-
method=HttpMethod.POST,
|
| 600 |
-
request_model=MoonvalleyTextToVideoRequest,
|
| 601 |
-
response_model=MoonvalleyPromptResponse
|
| 602 |
-
),
|
| 603 |
-
request=request,
|
| 604 |
-
auth_kwargs=kwargs,
|
| 605 |
-
)
|
| 606 |
-
task_creation_response = initial_operation.execute()
|
| 607 |
-
validate_task_creation_response(task_creation_response)
|
| 608 |
-
task_id = task_creation_response.id
|
| 609 |
-
|
| 610 |
-
final_response = self.get_response(
|
| 611 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 612 |
-
)
|
| 613 |
-
|
| 614 |
-
video = download_url_to_video_output(final_response.output_url)
|
| 615 |
-
return (video, )
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
NODE_CLASS_MAPPINGS = {
|
| 620 |
-
"MoonvalleyImg2VideoNode": MoonvalleyImg2VideoNode,
|
| 621 |
-
"MoonvalleyTxt2VideoNode": MoonvalleyTxt2VideoNode,
|
| 622 |
-
# "MoonvalleyVideo2VideoNode": MoonvalleyVideo2VideoNode,
|
| 623 |
-
}
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 627 |
-
"MoonvalleyImg2VideoNode": "Moonvalley Marey Image to Video",
|
| 628 |
-
"MoonvalleyTxt2VideoNode": "Moonvalley Marey Text to Video",
|
| 629 |
-
# "MoonvalleyVideo2VideoNode": "Moonvalley Marey Video to Video",
|
| 630 |
-
}
|
| 631 |
-
|
| 632 |
-
def get_total_frames_from_length(length="5s"):
|
| 633 |
-
# if length == '5s':
|
| 634 |
-
# return 128
|
| 635 |
-
# elif length == '10s':
|
| 636 |
-
# return 256
|
| 637 |
-
return 128
|
| 638 |
-
# else:
|
| 639 |
-
# raise MoonvalleyApiError("length is required")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_openai.py
DELETED
|
@@ -1,1008 +0,0 @@
|
|
| 1 |
-
import io
|
| 2 |
-
from typing import TypedDict, Optional
|
| 3 |
-
import json
|
| 4 |
-
import os
|
| 5 |
-
import time
|
| 6 |
-
import re
|
| 7 |
-
import uuid
|
| 8 |
-
from enum import Enum
|
| 9 |
-
from inspect import cleandoc
|
| 10 |
-
import numpy as np
|
| 11 |
-
import torch
|
| 12 |
-
from PIL import Image
|
| 13 |
-
from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeDict
|
| 14 |
-
from server import PromptServer
|
| 15 |
-
import folder_paths
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
from comfy_api_nodes.apis import (
|
| 19 |
-
OpenAIImageGenerationRequest,
|
| 20 |
-
OpenAIImageEditRequest,
|
| 21 |
-
OpenAIImageGenerationResponse,
|
| 22 |
-
OpenAICreateResponse,
|
| 23 |
-
OpenAIResponse,
|
| 24 |
-
CreateModelResponseProperties,
|
| 25 |
-
Item,
|
| 26 |
-
Includable,
|
| 27 |
-
OutputContent,
|
| 28 |
-
InputImageContent,
|
| 29 |
-
Detail,
|
| 30 |
-
InputTextContent,
|
| 31 |
-
InputMessage,
|
| 32 |
-
InputMessageContentList,
|
| 33 |
-
InputContent,
|
| 34 |
-
InputFileContent,
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
from comfy_api_nodes.apis.client import (
|
| 38 |
-
ApiEndpoint,
|
| 39 |
-
HttpMethod,
|
| 40 |
-
SynchronousOperation,
|
| 41 |
-
PollingOperation,
|
| 42 |
-
EmptyRequest,
|
| 43 |
-
)
|
| 44 |
-
|
| 45 |
-
from comfy_api_nodes.apinode_utils import (
|
| 46 |
-
downscale_image_tensor,
|
| 47 |
-
validate_and_cast_response,
|
| 48 |
-
validate_string,
|
| 49 |
-
tensor_to_base64_string,
|
| 50 |
-
text_filepath_to_data_uri,
|
| 51 |
-
)
|
| 52 |
-
from comfy_api_nodes.mapper_utils import model_field_to_node_input
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
RESPONSES_ENDPOINT = "/proxy/openai/v1/responses"
|
| 56 |
-
STARTING_POINT_ID_PATTERN = r"<starting_point_id:(.*)>"
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
class HistoryEntry(TypedDict):
|
| 60 |
-
"""Type definition for a single history entry in the chat."""
|
| 61 |
-
|
| 62 |
-
prompt: str
|
| 63 |
-
response: str
|
| 64 |
-
response_id: str
|
| 65 |
-
timestamp: float
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
class ChatHistory(TypedDict):
|
| 69 |
-
"""Type definition for the chat history dictionary."""
|
| 70 |
-
|
| 71 |
-
__annotations__: dict[str, list[HistoryEntry]]
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
class SupportedOpenAIModel(str, Enum):
|
| 75 |
-
o4_mini = "o4-mini"
|
| 76 |
-
o1 = "o1"
|
| 77 |
-
o3 = "o3"
|
| 78 |
-
o1_pro = "o1-pro"
|
| 79 |
-
gpt_4o = "gpt-4o"
|
| 80 |
-
gpt_4_1 = "gpt-4.1"
|
| 81 |
-
gpt_4_1_mini = "gpt-4.1-mini"
|
| 82 |
-
gpt_4_1_nano = "gpt-4.1-nano"
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
class OpenAIDalle2(ComfyNodeABC):
|
| 86 |
-
"""
|
| 87 |
-
Generates images synchronously via OpenAI's DALL·E 2 endpoint.
|
| 88 |
-
"""
|
| 89 |
-
|
| 90 |
-
def __init__(self):
|
| 91 |
-
pass
|
| 92 |
-
|
| 93 |
-
@classmethod
|
| 94 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 95 |
-
return {
|
| 96 |
-
"required": {
|
| 97 |
-
"prompt": (
|
| 98 |
-
IO.STRING,
|
| 99 |
-
{
|
| 100 |
-
"multiline": True,
|
| 101 |
-
"default": "",
|
| 102 |
-
"tooltip": "Text prompt for DALL·E",
|
| 103 |
-
},
|
| 104 |
-
),
|
| 105 |
-
},
|
| 106 |
-
"optional": {
|
| 107 |
-
"seed": (
|
| 108 |
-
IO.INT,
|
| 109 |
-
{
|
| 110 |
-
"default": 0,
|
| 111 |
-
"min": 0,
|
| 112 |
-
"max": 2**31 - 1,
|
| 113 |
-
"step": 1,
|
| 114 |
-
"display": "number",
|
| 115 |
-
"control_after_generate": True,
|
| 116 |
-
"tooltip": "not implemented yet in backend",
|
| 117 |
-
},
|
| 118 |
-
),
|
| 119 |
-
"size": (
|
| 120 |
-
IO.COMBO,
|
| 121 |
-
{
|
| 122 |
-
"options": ["256x256", "512x512", "1024x1024"],
|
| 123 |
-
"default": "1024x1024",
|
| 124 |
-
"tooltip": "Image size",
|
| 125 |
-
},
|
| 126 |
-
),
|
| 127 |
-
"n": (
|
| 128 |
-
IO.INT,
|
| 129 |
-
{
|
| 130 |
-
"default": 1,
|
| 131 |
-
"min": 1,
|
| 132 |
-
"max": 8,
|
| 133 |
-
"step": 1,
|
| 134 |
-
"display": "number",
|
| 135 |
-
"tooltip": "How many images to generate",
|
| 136 |
-
},
|
| 137 |
-
),
|
| 138 |
-
"image": (
|
| 139 |
-
IO.IMAGE,
|
| 140 |
-
{
|
| 141 |
-
"default": None,
|
| 142 |
-
"tooltip": "Optional reference image for image editing.",
|
| 143 |
-
},
|
| 144 |
-
),
|
| 145 |
-
"mask": (
|
| 146 |
-
IO.MASK,
|
| 147 |
-
{
|
| 148 |
-
"default": None,
|
| 149 |
-
"tooltip": "Optional mask for inpainting (white areas will be replaced)",
|
| 150 |
-
},
|
| 151 |
-
),
|
| 152 |
-
},
|
| 153 |
-
"hidden": {
|
| 154 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 155 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 156 |
-
"unique_id": "UNIQUE_ID",
|
| 157 |
-
},
|
| 158 |
-
}
|
| 159 |
-
|
| 160 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 161 |
-
FUNCTION = "api_call"
|
| 162 |
-
CATEGORY = "api node/image/OpenAI"
|
| 163 |
-
DESCRIPTION = cleandoc(__doc__ or "")
|
| 164 |
-
API_NODE = True
|
| 165 |
-
|
| 166 |
-
def api_call(
|
| 167 |
-
self,
|
| 168 |
-
prompt,
|
| 169 |
-
seed=0,
|
| 170 |
-
image=None,
|
| 171 |
-
mask=None,
|
| 172 |
-
n=1,
|
| 173 |
-
size="1024x1024",
|
| 174 |
-
unique_id=None,
|
| 175 |
-
**kwargs,
|
| 176 |
-
):
|
| 177 |
-
validate_string(prompt, strip_whitespace=False)
|
| 178 |
-
model = "dall-e-2"
|
| 179 |
-
path = "/proxy/openai/images/generations"
|
| 180 |
-
content_type = "application/json"
|
| 181 |
-
request_class = OpenAIImageGenerationRequest
|
| 182 |
-
img_binary = None
|
| 183 |
-
|
| 184 |
-
if image is not None and mask is not None:
|
| 185 |
-
path = "/proxy/openai/images/edits"
|
| 186 |
-
content_type = "multipart/form-data"
|
| 187 |
-
request_class = OpenAIImageEditRequest
|
| 188 |
-
|
| 189 |
-
input_tensor = image.squeeze().cpu()
|
| 190 |
-
height, width, channels = input_tensor.shape
|
| 191 |
-
rgba_tensor = torch.ones(height, width, 4, device="cpu")
|
| 192 |
-
rgba_tensor[:, :, :channels] = input_tensor
|
| 193 |
-
|
| 194 |
-
if mask.shape[1:] != image.shape[1:-1]:
|
| 195 |
-
raise Exception("Mask and Image must be the same size")
|
| 196 |
-
rgba_tensor[:, :, 3] = 1 - mask.squeeze().cpu()
|
| 197 |
-
|
| 198 |
-
rgba_tensor = downscale_image_tensor(rgba_tensor.unsqueeze(0)).squeeze()
|
| 199 |
-
|
| 200 |
-
image_np = (rgba_tensor.numpy() * 255).astype(np.uint8)
|
| 201 |
-
img = Image.fromarray(image_np)
|
| 202 |
-
img_byte_arr = io.BytesIO()
|
| 203 |
-
img.save(img_byte_arr, format="PNG")
|
| 204 |
-
img_byte_arr.seek(0)
|
| 205 |
-
img_binary = img_byte_arr # .getvalue()
|
| 206 |
-
img_binary.name = "image.png"
|
| 207 |
-
elif image is not None or mask is not None:
|
| 208 |
-
raise Exception("Dall-E 2 image editing requires an image AND a mask")
|
| 209 |
-
|
| 210 |
-
# Build the operation
|
| 211 |
-
operation = SynchronousOperation(
|
| 212 |
-
endpoint=ApiEndpoint(
|
| 213 |
-
path=path,
|
| 214 |
-
method=HttpMethod.POST,
|
| 215 |
-
request_model=request_class,
|
| 216 |
-
response_model=OpenAIImageGenerationResponse,
|
| 217 |
-
),
|
| 218 |
-
request=request_class(
|
| 219 |
-
model=model,
|
| 220 |
-
prompt=prompt,
|
| 221 |
-
n=n,
|
| 222 |
-
size=size,
|
| 223 |
-
seed=seed,
|
| 224 |
-
),
|
| 225 |
-
files=(
|
| 226 |
-
{
|
| 227 |
-
"image": img_binary,
|
| 228 |
-
}
|
| 229 |
-
if img_binary
|
| 230 |
-
else None
|
| 231 |
-
),
|
| 232 |
-
content_type=content_type,
|
| 233 |
-
auth_kwargs=kwargs,
|
| 234 |
-
)
|
| 235 |
-
|
| 236 |
-
response = operation.execute()
|
| 237 |
-
|
| 238 |
-
img_tensor = validate_and_cast_response(response, node_id=unique_id)
|
| 239 |
-
return (img_tensor,)
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
class OpenAIDalle3(ComfyNodeABC):
|
| 243 |
-
"""
|
| 244 |
-
Generates images synchronously via OpenAI's DALL·E 3 endpoint.
|
| 245 |
-
"""
|
| 246 |
-
|
| 247 |
-
def __init__(self):
|
| 248 |
-
pass
|
| 249 |
-
|
| 250 |
-
@classmethod
|
| 251 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 252 |
-
return {
|
| 253 |
-
"required": {
|
| 254 |
-
"prompt": (
|
| 255 |
-
IO.STRING,
|
| 256 |
-
{
|
| 257 |
-
"multiline": True,
|
| 258 |
-
"default": "",
|
| 259 |
-
"tooltip": "Text prompt for DALL·E",
|
| 260 |
-
},
|
| 261 |
-
),
|
| 262 |
-
},
|
| 263 |
-
"optional": {
|
| 264 |
-
"seed": (
|
| 265 |
-
IO.INT,
|
| 266 |
-
{
|
| 267 |
-
"default": 0,
|
| 268 |
-
"min": 0,
|
| 269 |
-
"max": 2**31 - 1,
|
| 270 |
-
"step": 1,
|
| 271 |
-
"display": "number",
|
| 272 |
-
"control_after_generate": True,
|
| 273 |
-
"tooltip": "not implemented yet in backend",
|
| 274 |
-
},
|
| 275 |
-
),
|
| 276 |
-
"quality": (
|
| 277 |
-
IO.COMBO,
|
| 278 |
-
{
|
| 279 |
-
"options": ["standard", "hd"],
|
| 280 |
-
"default": "standard",
|
| 281 |
-
"tooltip": "Image quality",
|
| 282 |
-
},
|
| 283 |
-
),
|
| 284 |
-
"style": (
|
| 285 |
-
IO.COMBO,
|
| 286 |
-
{
|
| 287 |
-
"options": ["natural", "vivid"],
|
| 288 |
-
"default": "natural",
|
| 289 |
-
"tooltip": "Vivid causes the model to lean towards generating hyper-real and dramatic images. Natural causes the model to produce more natural, less hyper-real looking images.",
|
| 290 |
-
},
|
| 291 |
-
),
|
| 292 |
-
"size": (
|
| 293 |
-
IO.COMBO,
|
| 294 |
-
{
|
| 295 |
-
"options": ["1024x1024", "1024x1792", "1792x1024"],
|
| 296 |
-
"default": "1024x1024",
|
| 297 |
-
"tooltip": "Image size",
|
| 298 |
-
},
|
| 299 |
-
),
|
| 300 |
-
},
|
| 301 |
-
"hidden": {
|
| 302 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 303 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 304 |
-
"unique_id": "UNIQUE_ID",
|
| 305 |
-
},
|
| 306 |
-
}
|
| 307 |
-
|
| 308 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 309 |
-
FUNCTION = "api_call"
|
| 310 |
-
CATEGORY = "api node/image/OpenAI"
|
| 311 |
-
DESCRIPTION = cleandoc(__doc__ or "")
|
| 312 |
-
API_NODE = True
|
| 313 |
-
|
| 314 |
-
def api_call(
|
| 315 |
-
self,
|
| 316 |
-
prompt,
|
| 317 |
-
seed=0,
|
| 318 |
-
style="natural",
|
| 319 |
-
quality="standard",
|
| 320 |
-
size="1024x1024",
|
| 321 |
-
unique_id=None,
|
| 322 |
-
**kwargs,
|
| 323 |
-
):
|
| 324 |
-
validate_string(prompt, strip_whitespace=False)
|
| 325 |
-
model = "dall-e-3"
|
| 326 |
-
|
| 327 |
-
# build the operation
|
| 328 |
-
operation = SynchronousOperation(
|
| 329 |
-
endpoint=ApiEndpoint(
|
| 330 |
-
path="/proxy/openai/images/generations",
|
| 331 |
-
method=HttpMethod.POST,
|
| 332 |
-
request_model=OpenAIImageGenerationRequest,
|
| 333 |
-
response_model=OpenAIImageGenerationResponse,
|
| 334 |
-
),
|
| 335 |
-
request=OpenAIImageGenerationRequest(
|
| 336 |
-
model=model,
|
| 337 |
-
prompt=prompt,
|
| 338 |
-
quality=quality,
|
| 339 |
-
size=size,
|
| 340 |
-
style=style,
|
| 341 |
-
seed=seed,
|
| 342 |
-
),
|
| 343 |
-
auth_kwargs=kwargs,
|
| 344 |
-
)
|
| 345 |
-
|
| 346 |
-
response = operation.execute()
|
| 347 |
-
|
| 348 |
-
img_tensor = validate_and_cast_response(response, node_id=unique_id)
|
| 349 |
-
return (img_tensor,)
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
class OpenAIGPTImage1(ComfyNodeABC):
|
| 353 |
-
"""
|
| 354 |
-
Generates images synchronously via OpenAI's GPT Image 1 endpoint.
|
| 355 |
-
"""
|
| 356 |
-
|
| 357 |
-
def __init__(self):
|
| 358 |
-
pass
|
| 359 |
-
|
| 360 |
-
@classmethod
|
| 361 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 362 |
-
return {
|
| 363 |
-
"required": {
|
| 364 |
-
"prompt": (
|
| 365 |
-
IO.STRING,
|
| 366 |
-
{
|
| 367 |
-
"multiline": True,
|
| 368 |
-
"default": "",
|
| 369 |
-
"tooltip": "Text prompt for GPT Image 1",
|
| 370 |
-
},
|
| 371 |
-
),
|
| 372 |
-
},
|
| 373 |
-
"optional": {
|
| 374 |
-
"seed": (
|
| 375 |
-
IO.INT,
|
| 376 |
-
{
|
| 377 |
-
"default": 0,
|
| 378 |
-
"min": 0,
|
| 379 |
-
"max": 2**31 - 1,
|
| 380 |
-
"step": 1,
|
| 381 |
-
"display": "number",
|
| 382 |
-
"control_after_generate": True,
|
| 383 |
-
"tooltip": "not implemented yet in backend",
|
| 384 |
-
},
|
| 385 |
-
),
|
| 386 |
-
"quality": (
|
| 387 |
-
IO.COMBO,
|
| 388 |
-
{
|
| 389 |
-
"options": ["low", "medium", "high"],
|
| 390 |
-
"default": "low",
|
| 391 |
-
"tooltip": "Image quality, affects cost and generation time.",
|
| 392 |
-
},
|
| 393 |
-
),
|
| 394 |
-
"background": (
|
| 395 |
-
IO.COMBO,
|
| 396 |
-
{
|
| 397 |
-
"options": ["opaque", "transparent"],
|
| 398 |
-
"default": "opaque",
|
| 399 |
-
"tooltip": "Return image with or without background",
|
| 400 |
-
},
|
| 401 |
-
),
|
| 402 |
-
"size": (
|
| 403 |
-
IO.COMBO,
|
| 404 |
-
{
|
| 405 |
-
"options": ["auto", "1024x1024", "1024x1536", "1536x1024"],
|
| 406 |
-
"default": "auto",
|
| 407 |
-
"tooltip": "Image size",
|
| 408 |
-
},
|
| 409 |
-
),
|
| 410 |
-
"n": (
|
| 411 |
-
IO.INT,
|
| 412 |
-
{
|
| 413 |
-
"default": 1,
|
| 414 |
-
"min": 1,
|
| 415 |
-
"max": 8,
|
| 416 |
-
"step": 1,
|
| 417 |
-
"display": "number",
|
| 418 |
-
"tooltip": "How many images to generate",
|
| 419 |
-
},
|
| 420 |
-
),
|
| 421 |
-
"image": (
|
| 422 |
-
IO.IMAGE,
|
| 423 |
-
{
|
| 424 |
-
"default": None,
|
| 425 |
-
"tooltip": "Optional reference image for image editing.",
|
| 426 |
-
},
|
| 427 |
-
),
|
| 428 |
-
"mask": (
|
| 429 |
-
IO.MASK,
|
| 430 |
-
{
|
| 431 |
-
"default": None,
|
| 432 |
-
"tooltip": "Optional mask for inpainting (white areas will be replaced)",
|
| 433 |
-
},
|
| 434 |
-
),
|
| 435 |
-
},
|
| 436 |
-
"hidden": {
|
| 437 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 438 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 439 |
-
"unique_id": "UNIQUE_ID",
|
| 440 |
-
},
|
| 441 |
-
}
|
| 442 |
-
|
| 443 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 444 |
-
FUNCTION = "api_call"
|
| 445 |
-
CATEGORY = "api node/image/OpenAI"
|
| 446 |
-
DESCRIPTION = cleandoc(__doc__ or "")
|
| 447 |
-
API_NODE = True
|
| 448 |
-
|
| 449 |
-
def api_call(
|
| 450 |
-
self,
|
| 451 |
-
prompt,
|
| 452 |
-
seed=0,
|
| 453 |
-
quality="low",
|
| 454 |
-
background="opaque",
|
| 455 |
-
image=None,
|
| 456 |
-
mask=None,
|
| 457 |
-
n=1,
|
| 458 |
-
size="1024x1024",
|
| 459 |
-
unique_id=None,
|
| 460 |
-
**kwargs,
|
| 461 |
-
):
|
| 462 |
-
validate_string(prompt, strip_whitespace=False)
|
| 463 |
-
model = "gpt-image-1"
|
| 464 |
-
path = "/proxy/openai/images/generations"
|
| 465 |
-
content_type = "application/json"
|
| 466 |
-
request_class = OpenAIImageGenerationRequest
|
| 467 |
-
img_binaries = []
|
| 468 |
-
mask_binary = None
|
| 469 |
-
files = []
|
| 470 |
-
|
| 471 |
-
if image is not None:
|
| 472 |
-
path = "/proxy/openai/images/edits"
|
| 473 |
-
request_class = OpenAIImageEditRequest
|
| 474 |
-
content_type = "multipart/form-data"
|
| 475 |
-
|
| 476 |
-
batch_size = image.shape[0]
|
| 477 |
-
|
| 478 |
-
for i in range(batch_size):
|
| 479 |
-
single_image = image[i : i + 1]
|
| 480 |
-
scaled_image = downscale_image_tensor(single_image).squeeze()
|
| 481 |
-
|
| 482 |
-
image_np = (scaled_image.numpy() * 255).astype(np.uint8)
|
| 483 |
-
img = Image.fromarray(image_np)
|
| 484 |
-
img_byte_arr = io.BytesIO()
|
| 485 |
-
img.save(img_byte_arr, format="PNG")
|
| 486 |
-
img_byte_arr.seek(0)
|
| 487 |
-
img_binary = img_byte_arr
|
| 488 |
-
img_binary.name = f"image_{i}.png"
|
| 489 |
-
|
| 490 |
-
img_binaries.append(img_binary)
|
| 491 |
-
if batch_size == 1:
|
| 492 |
-
files.append(("image", img_binary))
|
| 493 |
-
else:
|
| 494 |
-
files.append(("image[]", img_binary))
|
| 495 |
-
|
| 496 |
-
if mask is not None:
|
| 497 |
-
if image is None:
|
| 498 |
-
raise Exception("Cannot use a mask without an input image")
|
| 499 |
-
if image.shape[0] != 1:
|
| 500 |
-
raise Exception("Cannot use a mask with multiple image")
|
| 501 |
-
if mask.shape[1:] != image.shape[1:-1]:
|
| 502 |
-
raise Exception("Mask and Image must be the same size")
|
| 503 |
-
batch, height, width = mask.shape
|
| 504 |
-
rgba_mask = torch.zeros(height, width, 4, device="cpu")
|
| 505 |
-
rgba_mask[:, :, 3] = 1 - mask.squeeze().cpu()
|
| 506 |
-
|
| 507 |
-
scaled_mask = downscale_image_tensor(rgba_mask.unsqueeze(0)).squeeze()
|
| 508 |
-
|
| 509 |
-
mask_np = (scaled_mask.numpy() * 255).astype(np.uint8)
|
| 510 |
-
mask_img = Image.fromarray(mask_np)
|
| 511 |
-
mask_img_byte_arr = io.BytesIO()
|
| 512 |
-
mask_img.save(mask_img_byte_arr, format="PNG")
|
| 513 |
-
mask_img_byte_arr.seek(0)
|
| 514 |
-
mask_binary = mask_img_byte_arr
|
| 515 |
-
mask_binary.name = "mask.png"
|
| 516 |
-
files.append(("mask", mask_binary))
|
| 517 |
-
|
| 518 |
-
# Build the operation
|
| 519 |
-
operation = SynchronousOperation(
|
| 520 |
-
endpoint=ApiEndpoint(
|
| 521 |
-
path=path,
|
| 522 |
-
method=HttpMethod.POST,
|
| 523 |
-
request_model=request_class,
|
| 524 |
-
response_model=OpenAIImageGenerationResponse,
|
| 525 |
-
),
|
| 526 |
-
request=request_class(
|
| 527 |
-
model=model,
|
| 528 |
-
prompt=prompt,
|
| 529 |
-
quality=quality,
|
| 530 |
-
background=background,
|
| 531 |
-
n=n,
|
| 532 |
-
seed=seed,
|
| 533 |
-
size=size,
|
| 534 |
-
),
|
| 535 |
-
files=files if files else None,
|
| 536 |
-
content_type=content_type,
|
| 537 |
-
auth_kwargs=kwargs,
|
| 538 |
-
)
|
| 539 |
-
|
| 540 |
-
response = operation.execute()
|
| 541 |
-
|
| 542 |
-
img_tensor = validate_and_cast_response(response, node_id=unique_id)
|
| 543 |
-
return (img_tensor,)
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
class OpenAITextNode(ComfyNodeABC):
|
| 547 |
-
"""
|
| 548 |
-
Base class for OpenAI text generation nodes.
|
| 549 |
-
"""
|
| 550 |
-
|
| 551 |
-
RETURN_TYPES = (IO.STRING,)
|
| 552 |
-
FUNCTION = "api_call"
|
| 553 |
-
CATEGORY = "api node/text/OpenAI"
|
| 554 |
-
API_NODE = True
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
class OpenAIChatNode(OpenAITextNode):
|
| 558 |
-
"""
|
| 559 |
-
Node to generate text responses from an OpenAI model.
|
| 560 |
-
"""
|
| 561 |
-
|
| 562 |
-
def __init__(self) -> None:
|
| 563 |
-
"""Initialize the chat node with a new session ID and empty history."""
|
| 564 |
-
self.current_session_id: str = str(uuid.uuid4())
|
| 565 |
-
self.history: dict[str, list[HistoryEntry]] = {}
|
| 566 |
-
self.previous_response_id: Optional[str] = None
|
| 567 |
-
|
| 568 |
-
@classmethod
|
| 569 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 570 |
-
return {
|
| 571 |
-
"required": {
|
| 572 |
-
"prompt": (
|
| 573 |
-
IO.STRING,
|
| 574 |
-
{
|
| 575 |
-
"multiline": True,
|
| 576 |
-
"default": "",
|
| 577 |
-
"tooltip": "Text inputs to the model, used to generate a response.",
|
| 578 |
-
},
|
| 579 |
-
),
|
| 580 |
-
"persist_context": (
|
| 581 |
-
IO.BOOLEAN,
|
| 582 |
-
{
|
| 583 |
-
"default": True,
|
| 584 |
-
"tooltip": "Persist chat context between calls (multi-turn conversation)",
|
| 585 |
-
},
|
| 586 |
-
),
|
| 587 |
-
"model": model_field_to_node_input(
|
| 588 |
-
IO.COMBO,
|
| 589 |
-
OpenAICreateResponse,
|
| 590 |
-
"model",
|
| 591 |
-
enum_type=SupportedOpenAIModel,
|
| 592 |
-
),
|
| 593 |
-
},
|
| 594 |
-
"optional": {
|
| 595 |
-
"images": (
|
| 596 |
-
IO.IMAGE,
|
| 597 |
-
{
|
| 598 |
-
"default": None,
|
| 599 |
-
"tooltip": "Optional image(s) to use as context for the model. To include multiple images, you can use the Batch Images node.",
|
| 600 |
-
},
|
| 601 |
-
),
|
| 602 |
-
"files": (
|
| 603 |
-
"OPENAI_INPUT_FILES",
|
| 604 |
-
{
|
| 605 |
-
"default": None,
|
| 606 |
-
"tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the OpenAI Chat Input Files node.",
|
| 607 |
-
},
|
| 608 |
-
),
|
| 609 |
-
"advanced_options": (
|
| 610 |
-
"OPENAI_CHAT_CONFIG",
|
| 611 |
-
{
|
| 612 |
-
"default": None,
|
| 613 |
-
"tooltip": "Optional configuration for the model. Accepts inputs from the OpenAI Chat Advanced Options node.",
|
| 614 |
-
},
|
| 615 |
-
),
|
| 616 |
-
},
|
| 617 |
-
"hidden": {
|
| 618 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 619 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 620 |
-
"unique_id": "UNIQUE_ID",
|
| 621 |
-
},
|
| 622 |
-
}
|
| 623 |
-
|
| 624 |
-
DESCRIPTION = "Generate text responses from an OpenAI model."
|
| 625 |
-
|
| 626 |
-
def get_result_response(
|
| 627 |
-
self,
|
| 628 |
-
response_id: str,
|
| 629 |
-
include: Optional[list[Includable]] = None,
|
| 630 |
-
auth_kwargs: Optional[dict[str, str]] = None,
|
| 631 |
-
) -> OpenAIResponse:
|
| 632 |
-
"""
|
| 633 |
-
Retrieve a model response with the given ID from the OpenAI API.
|
| 634 |
-
|
| 635 |
-
Args:
|
| 636 |
-
response_id (str): The ID of the response to retrieve.
|
| 637 |
-
include (Optional[List[Includable]]): Additional fields to include
|
| 638 |
-
in the response. See the `include` parameter for Response
|
| 639 |
-
creation above for more information.
|
| 640 |
-
|
| 641 |
-
"""
|
| 642 |
-
return PollingOperation(
|
| 643 |
-
poll_endpoint=ApiEndpoint(
|
| 644 |
-
path=f"{RESPONSES_ENDPOINT}/{response_id}",
|
| 645 |
-
method=HttpMethod.GET,
|
| 646 |
-
request_model=EmptyRequest,
|
| 647 |
-
response_model=OpenAIResponse,
|
| 648 |
-
query_params={"include": include},
|
| 649 |
-
),
|
| 650 |
-
completed_statuses=["completed"],
|
| 651 |
-
failed_statuses=["failed"],
|
| 652 |
-
status_extractor=lambda response: response.status,
|
| 653 |
-
auth_kwargs=auth_kwargs,
|
| 654 |
-
).execute()
|
| 655 |
-
|
| 656 |
-
def get_message_content_from_response(
|
| 657 |
-
self, response: OpenAIResponse
|
| 658 |
-
) -> list[OutputContent]:
|
| 659 |
-
"""Extract message content from the API response."""
|
| 660 |
-
for output in response.output:
|
| 661 |
-
if output.root.type == "message":
|
| 662 |
-
return output.root.content
|
| 663 |
-
raise TypeError("No output message found in response")
|
| 664 |
-
|
| 665 |
-
def get_text_from_message_content(
|
| 666 |
-
self, message_content: list[OutputContent]
|
| 667 |
-
) -> str:
|
| 668 |
-
"""Extract text content from message content."""
|
| 669 |
-
for content_item in message_content:
|
| 670 |
-
if content_item.root.type == "output_text":
|
| 671 |
-
return str(content_item.root.text)
|
| 672 |
-
return "No text output found in response"
|
| 673 |
-
|
| 674 |
-
def get_history_text(self, session_id: str) -> str:
|
| 675 |
-
"""Convert the entire history for a given session to JSON string."""
|
| 676 |
-
return json.dumps(self.history[session_id])
|
| 677 |
-
|
| 678 |
-
def display_history_on_node(self, session_id: str, node_id: str) -> None:
|
| 679 |
-
"""Display formatted chat history on the node UI."""
|
| 680 |
-
render_spec = {
|
| 681 |
-
"node_id": node_id,
|
| 682 |
-
"component": "ChatHistoryWidget",
|
| 683 |
-
"props": {
|
| 684 |
-
"history": self.get_history_text(session_id),
|
| 685 |
-
},
|
| 686 |
-
}
|
| 687 |
-
PromptServer.instance.send_sync(
|
| 688 |
-
"display_component",
|
| 689 |
-
render_spec,
|
| 690 |
-
)
|
| 691 |
-
|
| 692 |
-
def add_to_history(
|
| 693 |
-
self, session_id: str, prompt: str, output_text: str, response_id: str
|
| 694 |
-
) -> None:
|
| 695 |
-
"""Add a new entry to the chat history."""
|
| 696 |
-
if session_id not in self.history:
|
| 697 |
-
self.history[session_id] = []
|
| 698 |
-
self.history[session_id].append(
|
| 699 |
-
{
|
| 700 |
-
"prompt": prompt,
|
| 701 |
-
"response": output_text,
|
| 702 |
-
"response_id": response_id,
|
| 703 |
-
"timestamp": time.time(),
|
| 704 |
-
}
|
| 705 |
-
)
|
| 706 |
-
|
| 707 |
-
def parse_output_text_from_response(self, response: OpenAIResponse) -> str:
|
| 708 |
-
"""Extract text output from the API response."""
|
| 709 |
-
message_contents = self.get_message_content_from_response(response)
|
| 710 |
-
return self.get_text_from_message_content(message_contents)
|
| 711 |
-
|
| 712 |
-
def generate_new_session_id(self) -> str:
|
| 713 |
-
"""Generate a new unique session ID."""
|
| 714 |
-
return str(uuid.uuid4())
|
| 715 |
-
|
| 716 |
-
def get_session_id(self, persist_context: bool) -> str:
|
| 717 |
-
"""Get the current or generate a new session ID based on context persistence."""
|
| 718 |
-
return (
|
| 719 |
-
self.current_session_id
|
| 720 |
-
if persist_context
|
| 721 |
-
else self.generate_new_session_id()
|
| 722 |
-
)
|
| 723 |
-
|
| 724 |
-
def tensor_to_input_image_content(
|
| 725 |
-
self, image: torch.Tensor, detail_level: Detail = "auto"
|
| 726 |
-
) -> InputImageContent:
|
| 727 |
-
"""Convert a tensor to an input image content object."""
|
| 728 |
-
return InputImageContent(
|
| 729 |
-
detail=detail_level,
|
| 730 |
-
image_url=f"data:image/png;base64,{tensor_to_base64_string(image)}",
|
| 731 |
-
type="input_image",
|
| 732 |
-
)
|
| 733 |
-
|
| 734 |
-
def create_input_message_contents(
|
| 735 |
-
self,
|
| 736 |
-
prompt: str,
|
| 737 |
-
image: Optional[torch.Tensor] = None,
|
| 738 |
-
files: Optional[list[InputFileContent]] = None,
|
| 739 |
-
) -> InputMessageContentList:
|
| 740 |
-
"""Create a list of input message contents from prompt and optional image."""
|
| 741 |
-
content_list: list[InputContent] = [
|
| 742 |
-
InputTextContent(text=prompt, type="input_text"),
|
| 743 |
-
]
|
| 744 |
-
if image is not None:
|
| 745 |
-
for i in range(image.shape[0]):
|
| 746 |
-
content_list.append(
|
| 747 |
-
self.tensor_to_input_image_content(image[i].unsqueeze(0))
|
| 748 |
-
)
|
| 749 |
-
if files is not None:
|
| 750 |
-
content_list.extend(files)
|
| 751 |
-
|
| 752 |
-
return InputMessageContentList(
|
| 753 |
-
root=content_list,
|
| 754 |
-
)
|
| 755 |
-
|
| 756 |
-
def parse_response_id_from_prompt(self, prompt: str) -> Optional[str]:
|
| 757 |
-
"""Extract response ID from prompt if it exists."""
|
| 758 |
-
parsed_id = re.search(STARTING_POINT_ID_PATTERN, prompt)
|
| 759 |
-
return parsed_id.group(1) if parsed_id else None
|
| 760 |
-
|
| 761 |
-
def strip_response_tag_from_prompt(self, prompt: str) -> str:
|
| 762 |
-
"""Remove the response ID tag from the prompt."""
|
| 763 |
-
return re.sub(STARTING_POINT_ID_PATTERN, "", prompt.strip())
|
| 764 |
-
|
| 765 |
-
def delete_history_after_response_id(
|
| 766 |
-
self, new_start_id: str, session_id: str
|
| 767 |
-
) -> None:
|
| 768 |
-
"""Delete history entries after a specific response ID."""
|
| 769 |
-
if session_id not in self.history:
|
| 770 |
-
return
|
| 771 |
-
|
| 772 |
-
new_history = []
|
| 773 |
-
i = 0
|
| 774 |
-
while (
|
| 775 |
-
i < len(self.history[session_id])
|
| 776 |
-
and self.history[session_id][i]["response_id"] != new_start_id
|
| 777 |
-
):
|
| 778 |
-
new_history.append(self.history[session_id][i])
|
| 779 |
-
i += 1
|
| 780 |
-
|
| 781 |
-
# Since it's the new starting point (not the response being edited), we include it as well
|
| 782 |
-
if i < len(self.history[session_id]):
|
| 783 |
-
new_history.append(self.history[session_id][i])
|
| 784 |
-
|
| 785 |
-
self.history[session_id] = new_history
|
| 786 |
-
|
| 787 |
-
def api_call(
|
| 788 |
-
self,
|
| 789 |
-
prompt: str,
|
| 790 |
-
persist_context: bool,
|
| 791 |
-
model: SupportedOpenAIModel,
|
| 792 |
-
unique_id: Optional[str] = None,
|
| 793 |
-
images: Optional[torch.Tensor] = None,
|
| 794 |
-
files: Optional[list[InputFileContent]] = None,
|
| 795 |
-
advanced_options: Optional[CreateModelResponseProperties] = None,
|
| 796 |
-
**kwargs,
|
| 797 |
-
) -> tuple[str]:
|
| 798 |
-
# Validate inputs
|
| 799 |
-
validate_string(prompt, strip_whitespace=False)
|
| 800 |
-
|
| 801 |
-
session_id = self.get_session_id(persist_context)
|
| 802 |
-
response_id_override = self.parse_response_id_from_prompt(prompt)
|
| 803 |
-
if response_id_override:
|
| 804 |
-
is_starting_from_beginning = response_id_override == "start"
|
| 805 |
-
if is_starting_from_beginning:
|
| 806 |
-
self.history[session_id] = []
|
| 807 |
-
previous_response_id = None
|
| 808 |
-
else:
|
| 809 |
-
previous_response_id = response_id_override
|
| 810 |
-
self.delete_history_after_response_id(response_id_override, session_id)
|
| 811 |
-
prompt = self.strip_response_tag_from_prompt(prompt)
|
| 812 |
-
elif persist_context:
|
| 813 |
-
previous_response_id = self.previous_response_id
|
| 814 |
-
else:
|
| 815 |
-
previous_response_id = None
|
| 816 |
-
|
| 817 |
-
# Create response
|
| 818 |
-
create_response = SynchronousOperation(
|
| 819 |
-
endpoint=ApiEndpoint(
|
| 820 |
-
path=RESPONSES_ENDPOINT,
|
| 821 |
-
method=HttpMethod.POST,
|
| 822 |
-
request_model=OpenAICreateResponse,
|
| 823 |
-
response_model=OpenAIResponse,
|
| 824 |
-
),
|
| 825 |
-
request=OpenAICreateResponse(
|
| 826 |
-
input=[
|
| 827 |
-
Item(
|
| 828 |
-
root=InputMessage(
|
| 829 |
-
content=self.create_input_message_contents(
|
| 830 |
-
prompt, images, files
|
| 831 |
-
),
|
| 832 |
-
role="user",
|
| 833 |
-
)
|
| 834 |
-
),
|
| 835 |
-
],
|
| 836 |
-
store=True,
|
| 837 |
-
stream=False,
|
| 838 |
-
model=model,
|
| 839 |
-
previous_response_id=previous_response_id,
|
| 840 |
-
**(
|
| 841 |
-
advanced_options.model_dump(exclude_none=True)
|
| 842 |
-
if advanced_options
|
| 843 |
-
else {}
|
| 844 |
-
),
|
| 845 |
-
),
|
| 846 |
-
auth_kwargs=kwargs,
|
| 847 |
-
).execute()
|
| 848 |
-
response_id = create_response.id
|
| 849 |
-
|
| 850 |
-
# Get result output
|
| 851 |
-
result_response = self.get_result_response(response_id, auth_kwargs=kwargs)
|
| 852 |
-
output_text = self.parse_output_text_from_response(result_response)
|
| 853 |
-
|
| 854 |
-
# Update history
|
| 855 |
-
self.add_to_history(session_id, prompt, output_text, response_id)
|
| 856 |
-
self.display_history_on_node(session_id, unique_id)
|
| 857 |
-
self.previous_response_id = response_id
|
| 858 |
-
|
| 859 |
-
return (output_text,)
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
class OpenAIInputFiles(ComfyNodeABC):
|
| 863 |
-
"""
|
| 864 |
-
Loads and formats input files for OpenAI API.
|
| 865 |
-
"""
|
| 866 |
-
|
| 867 |
-
@classmethod
|
| 868 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 869 |
-
"""
|
| 870 |
-
For details about the supported file input types, see:
|
| 871 |
-
https://platform.openai.com/docs/guides/pdf-files?api-mode=responses
|
| 872 |
-
"""
|
| 873 |
-
input_dir = folder_paths.get_input_directory()
|
| 874 |
-
input_files = [
|
| 875 |
-
f
|
| 876 |
-
for f in os.scandir(input_dir)
|
| 877 |
-
if f.is_file()
|
| 878 |
-
and (f.name.endswith(".txt") or f.name.endswith(".pdf"))
|
| 879 |
-
and f.stat().st_size < 32 * 1024 * 1024
|
| 880 |
-
]
|
| 881 |
-
input_files = sorted(input_files, key=lambda x: x.name)
|
| 882 |
-
input_files = [f.name for f in input_files]
|
| 883 |
-
return {
|
| 884 |
-
"required": {
|
| 885 |
-
"file": (
|
| 886 |
-
IO.COMBO,
|
| 887 |
-
{
|
| 888 |
-
"tooltip": "Input files to include as context for the model. Only accepts text (.txt) and PDF (.pdf) files for now.",
|
| 889 |
-
"options": input_files,
|
| 890 |
-
"default": input_files[0] if input_files else None,
|
| 891 |
-
},
|
| 892 |
-
),
|
| 893 |
-
},
|
| 894 |
-
"optional": {
|
| 895 |
-
"OPENAI_INPUT_FILES": (
|
| 896 |
-
"OPENAI_INPUT_FILES",
|
| 897 |
-
{
|
| 898 |
-
"tooltip": "An optional additional file(s) to batch together with the file loaded from this node. Allows chaining of input files so that a single message can include multiple input files.",
|
| 899 |
-
"default": None,
|
| 900 |
-
},
|
| 901 |
-
),
|
| 902 |
-
},
|
| 903 |
-
}
|
| 904 |
-
|
| 905 |
-
DESCRIPTION = "Loads and prepares input files (text, pdf, etc.) to include as inputs for the OpenAI Chat Node. The files will be read by the OpenAI model when generating a response. 🛈 TIP: Can be chained together with other OpenAI Input File nodes."
|
| 906 |
-
RETURN_TYPES = ("OPENAI_INPUT_FILES",)
|
| 907 |
-
FUNCTION = "prepare_files"
|
| 908 |
-
CATEGORY = "api node/text/OpenAI"
|
| 909 |
-
|
| 910 |
-
def create_input_file_content(self, file_path: str) -> InputFileContent:
|
| 911 |
-
return InputFileContent(
|
| 912 |
-
file_data=text_filepath_to_data_uri(file_path),
|
| 913 |
-
filename=os.path.basename(file_path),
|
| 914 |
-
type="input_file",
|
| 915 |
-
)
|
| 916 |
-
|
| 917 |
-
def prepare_files(
|
| 918 |
-
self, file: str, OPENAI_INPUT_FILES: list[InputFileContent] = []
|
| 919 |
-
) -> tuple[list[InputFileContent]]:
|
| 920 |
-
"""
|
| 921 |
-
Loads and formats input files for OpenAI API.
|
| 922 |
-
"""
|
| 923 |
-
file_path = folder_paths.get_annotated_filepath(file)
|
| 924 |
-
input_file_content = self.create_input_file_content(file_path)
|
| 925 |
-
files = [input_file_content] + OPENAI_INPUT_FILES
|
| 926 |
-
return (files,)
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
class OpenAIChatConfig(ComfyNodeABC):
|
| 930 |
-
"""Allows setting additional configuration for the OpenAI Chat Node."""
|
| 931 |
-
|
| 932 |
-
RETURN_TYPES = ("OPENAI_CHAT_CONFIG",)
|
| 933 |
-
FUNCTION = "configure"
|
| 934 |
-
DESCRIPTION = (
|
| 935 |
-
"Allows specifying advanced configuration options for the OpenAI Chat Nodes."
|
| 936 |
-
)
|
| 937 |
-
CATEGORY = "api node/text/OpenAI"
|
| 938 |
-
|
| 939 |
-
@classmethod
|
| 940 |
-
def INPUT_TYPES(cls) -> InputTypeDict:
|
| 941 |
-
return {
|
| 942 |
-
"required": {
|
| 943 |
-
"truncation": (
|
| 944 |
-
IO.COMBO,
|
| 945 |
-
{
|
| 946 |
-
"options": ["auto", "disabled"],
|
| 947 |
-
"default": "auto",
|
| 948 |
-
"tooltip": "The truncation strategy to use for the model response. auto: If the context of this response and previous ones exceeds the model's context window size, the model will truncate the response to fit the context window by dropping input items in the middle of the conversation.disabled: If a model response will exceed the context window size for a model, the request will fail with a 400 error",
|
| 949 |
-
},
|
| 950 |
-
),
|
| 951 |
-
},
|
| 952 |
-
"optional": {
|
| 953 |
-
"max_output_tokens": model_field_to_node_input(
|
| 954 |
-
IO.INT,
|
| 955 |
-
OpenAICreateResponse,
|
| 956 |
-
"max_output_tokens",
|
| 957 |
-
min=16,
|
| 958 |
-
default=4096,
|
| 959 |
-
max=16384,
|
| 960 |
-
tooltip="An upper bound for the number of tokens that can be generated for a response, including visible output tokens",
|
| 961 |
-
),
|
| 962 |
-
"instructions": model_field_to_node_input(
|
| 963 |
-
IO.STRING, OpenAICreateResponse, "instructions", multiline=True
|
| 964 |
-
),
|
| 965 |
-
},
|
| 966 |
-
}
|
| 967 |
-
|
| 968 |
-
def configure(
|
| 969 |
-
self,
|
| 970 |
-
truncation: bool,
|
| 971 |
-
instructions: Optional[str] = None,
|
| 972 |
-
max_output_tokens: Optional[int] = None,
|
| 973 |
-
) -> tuple[CreateModelResponseProperties]:
|
| 974 |
-
"""
|
| 975 |
-
Configure advanced options for the OpenAI Chat Node.
|
| 976 |
-
|
| 977 |
-
Note:
|
| 978 |
-
While `top_p` and `temperature` are listed as properties in the
|
| 979 |
-
spec, they are not supported for all models (e.g., o4-mini).
|
| 980 |
-
They are not exposed as inputs at all to avoid having to manually
|
| 981 |
-
remove depending on model choice.
|
| 982 |
-
"""
|
| 983 |
-
return (
|
| 984 |
-
CreateModelResponseProperties(
|
| 985 |
-
instructions=instructions,
|
| 986 |
-
truncation=truncation,
|
| 987 |
-
max_output_tokens=max_output_tokens,
|
| 988 |
-
),
|
| 989 |
-
)
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
NODE_CLASS_MAPPINGS = {
|
| 993 |
-
"OpenAIDalle2": OpenAIDalle2,
|
| 994 |
-
"OpenAIDalle3": OpenAIDalle3,
|
| 995 |
-
"OpenAIGPTImage1": OpenAIGPTImage1,
|
| 996 |
-
"OpenAIChatNode": OpenAIChatNode,
|
| 997 |
-
"OpenAIInputFiles": OpenAIInputFiles,
|
| 998 |
-
"OpenAIChatConfig": OpenAIChatConfig,
|
| 999 |
-
}
|
| 1000 |
-
|
| 1001 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 1002 |
-
"OpenAIDalle2": "OpenAI DALL·E 2",
|
| 1003 |
-
"OpenAIDalle3": "OpenAI DALL·E 3",
|
| 1004 |
-
"OpenAIGPTImage1": "OpenAI GPT Image 1",
|
| 1005 |
-
"OpenAIChatNode": "OpenAI Chat",
|
| 1006 |
-
"OpenAIInputFiles": "OpenAI Chat Input Files",
|
| 1007 |
-
"OpenAIChatConfig": "OpenAI Chat Advanced Options",
|
| 1008 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_pika.py
DELETED
|
@@ -1,782 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Pika x ComfyUI API Nodes
|
| 3 |
-
|
| 4 |
-
Pika API docs: https://pika-827374fb.mintlify.app/api-reference
|
| 5 |
-
"""
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import io
|
| 9 |
-
import logging
|
| 10 |
-
from typing import Optional, TypeVar
|
| 11 |
-
|
| 12 |
-
import numpy as np
|
| 13 |
-
import torch
|
| 14 |
-
|
| 15 |
-
from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeOptions
|
| 16 |
-
from comfy_api.input_impl import VideoFromFile
|
| 17 |
-
from comfy_api.input_impl.video_types import VideoCodec, VideoContainer, VideoInput
|
| 18 |
-
from comfy_api_nodes.apinode_utils import (
|
| 19 |
-
download_url_to_video_output,
|
| 20 |
-
tensor_to_bytesio,
|
| 21 |
-
)
|
| 22 |
-
from comfy_api_nodes.apis import (
|
| 23 |
-
IngredientsMode,
|
| 24 |
-
PikaBodyGenerate22C2vGenerate22PikascenesPost,
|
| 25 |
-
PikaBodyGenerate22I2vGenerate22I2vPost,
|
| 26 |
-
PikaBodyGenerate22KeyframeGenerate22PikaframesPost,
|
| 27 |
-
PikaBodyGenerate22T2vGenerate22T2vPost,
|
| 28 |
-
PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
|
| 29 |
-
PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
|
| 30 |
-
PikaBodyGeneratePikaswapsGeneratePikaswapsPost,
|
| 31 |
-
PikaDurationEnum,
|
| 32 |
-
Pikaffect,
|
| 33 |
-
PikaGenerateResponse,
|
| 34 |
-
PikaResolutionEnum,
|
| 35 |
-
PikaVideoResponse,
|
| 36 |
-
)
|
| 37 |
-
from comfy_api_nodes.apis.client import (
|
| 38 |
-
ApiEndpoint,
|
| 39 |
-
EmptyRequest,
|
| 40 |
-
HttpMethod,
|
| 41 |
-
PollingOperation,
|
| 42 |
-
SynchronousOperation,
|
| 43 |
-
)
|
| 44 |
-
from comfy_api_nodes.mapper_utils import model_field_to_node_input
|
| 45 |
-
|
| 46 |
-
R = TypeVar("R")
|
| 47 |
-
|
| 48 |
-
PATH_PIKADDITIONS = "/proxy/pika/generate/pikadditions"
|
| 49 |
-
PATH_PIKASWAPS = "/proxy/pika/generate/pikaswaps"
|
| 50 |
-
PATH_PIKAFFECTS = "/proxy/pika/generate/pikaffects"
|
| 51 |
-
|
| 52 |
-
PIKA_API_VERSION = "2.2"
|
| 53 |
-
PATH_TEXT_TO_VIDEO = f"/proxy/pika/generate/{PIKA_API_VERSION}/t2v"
|
| 54 |
-
PATH_IMAGE_TO_VIDEO = f"/proxy/pika/generate/{PIKA_API_VERSION}/i2v"
|
| 55 |
-
PATH_PIKAFRAMES = f"/proxy/pika/generate/{PIKA_API_VERSION}/pikaframes"
|
| 56 |
-
PATH_PIKASCENES = f"/proxy/pika/generate/{PIKA_API_VERSION}/pikascenes"
|
| 57 |
-
|
| 58 |
-
PATH_VIDEO_GET = "/proxy/pika/videos"
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
class PikaApiError(Exception):
|
| 62 |
-
"""Exception for Pika API errors."""
|
| 63 |
-
|
| 64 |
-
pass
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def is_valid_video_response(response: PikaVideoResponse) -> bool:
|
| 68 |
-
"""Check if the video response is valid."""
|
| 69 |
-
return hasattr(response, "url") and response.url is not None
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def is_valid_initial_response(response: PikaGenerateResponse) -> bool:
|
| 73 |
-
"""Check if the initial response is valid."""
|
| 74 |
-
return hasattr(response, "video_id") and response.video_id is not None
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
class PikaNodeBase(ComfyNodeABC):
|
| 78 |
-
"""Base class for Pika nodes."""
|
| 79 |
-
|
| 80 |
-
@classmethod
|
| 81 |
-
def get_base_inputs_types(
|
| 82 |
-
cls, request_model
|
| 83 |
-
) -> dict[str, tuple[IO, InputTypeOptions]]:
|
| 84 |
-
"""Get the base required inputs types common to all Pika nodes."""
|
| 85 |
-
return {
|
| 86 |
-
"prompt_text": model_field_to_node_input(
|
| 87 |
-
IO.STRING,
|
| 88 |
-
request_model,
|
| 89 |
-
"promptText",
|
| 90 |
-
multiline=True,
|
| 91 |
-
),
|
| 92 |
-
"negative_prompt": model_field_to_node_input(
|
| 93 |
-
IO.STRING,
|
| 94 |
-
request_model,
|
| 95 |
-
"negativePrompt",
|
| 96 |
-
multiline=True,
|
| 97 |
-
),
|
| 98 |
-
"seed": model_field_to_node_input(
|
| 99 |
-
IO.INT,
|
| 100 |
-
request_model,
|
| 101 |
-
"seed",
|
| 102 |
-
min=0,
|
| 103 |
-
max=0xFFFFFFFF,
|
| 104 |
-
control_after_generate=True,
|
| 105 |
-
),
|
| 106 |
-
"resolution": model_field_to_node_input(
|
| 107 |
-
IO.COMBO,
|
| 108 |
-
request_model,
|
| 109 |
-
"resolution",
|
| 110 |
-
enum_type=PikaResolutionEnum,
|
| 111 |
-
),
|
| 112 |
-
"duration": model_field_to_node_input(
|
| 113 |
-
IO.COMBO,
|
| 114 |
-
request_model,
|
| 115 |
-
"duration",
|
| 116 |
-
enum_type=PikaDurationEnum,
|
| 117 |
-
),
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
CATEGORY = "api node/video/Pika"
|
| 121 |
-
API_NODE = True
|
| 122 |
-
FUNCTION = "api_call"
|
| 123 |
-
RETURN_TYPES = ("VIDEO",)
|
| 124 |
-
|
| 125 |
-
def poll_for_task_status(
|
| 126 |
-
self,
|
| 127 |
-
task_id: str,
|
| 128 |
-
auth_kwargs: Optional[dict[str, str]] = None,
|
| 129 |
-
node_id: Optional[str] = None,
|
| 130 |
-
) -> PikaGenerateResponse:
|
| 131 |
-
polling_operation = PollingOperation(
|
| 132 |
-
poll_endpoint=ApiEndpoint(
|
| 133 |
-
path=f"{PATH_VIDEO_GET}/{task_id}",
|
| 134 |
-
method=HttpMethod.GET,
|
| 135 |
-
request_model=EmptyRequest,
|
| 136 |
-
response_model=PikaVideoResponse,
|
| 137 |
-
),
|
| 138 |
-
completed_statuses=[
|
| 139 |
-
"finished",
|
| 140 |
-
],
|
| 141 |
-
failed_statuses=["failed", "cancelled"],
|
| 142 |
-
status_extractor=lambda response: (
|
| 143 |
-
response.status.value if response.status else None
|
| 144 |
-
),
|
| 145 |
-
progress_extractor=lambda response: (
|
| 146 |
-
response.progress if hasattr(response, "progress") else None
|
| 147 |
-
),
|
| 148 |
-
auth_kwargs=auth_kwargs,
|
| 149 |
-
result_url_extractor=lambda response: (
|
| 150 |
-
response.url if hasattr(response, "url") else None
|
| 151 |
-
),
|
| 152 |
-
node_id=node_id,
|
| 153 |
-
estimated_duration=60
|
| 154 |
-
)
|
| 155 |
-
return polling_operation.execute()
|
| 156 |
-
|
| 157 |
-
def execute_task(
|
| 158 |
-
self,
|
| 159 |
-
initial_operation: SynchronousOperation[R, PikaGenerateResponse],
|
| 160 |
-
auth_kwargs: Optional[dict[str, str]] = None,
|
| 161 |
-
node_id: Optional[str] = None,
|
| 162 |
-
) -> tuple[VideoFromFile]:
|
| 163 |
-
"""Executes the initial operation then polls for the task status until it is completed.
|
| 164 |
-
|
| 165 |
-
Args:
|
| 166 |
-
initial_operation: The initial operation to execute.
|
| 167 |
-
auth_kwargs: The authentication token(s) to use for the API call.
|
| 168 |
-
|
| 169 |
-
Returns:
|
| 170 |
-
A tuple containing the video file as a VIDEO output.
|
| 171 |
-
"""
|
| 172 |
-
initial_response = initial_operation.execute()
|
| 173 |
-
if not is_valid_initial_response(initial_response):
|
| 174 |
-
error_msg = f"Pika initial request failed. Code: {initial_response.code}, Message: {initial_response.message}, Data: {initial_response.data}"
|
| 175 |
-
logging.error(error_msg)
|
| 176 |
-
raise PikaApiError(error_msg)
|
| 177 |
-
|
| 178 |
-
task_id = initial_response.video_id
|
| 179 |
-
final_response = self.poll_for_task_status(task_id, auth_kwargs)
|
| 180 |
-
if not is_valid_video_response(final_response):
|
| 181 |
-
error_msg = (
|
| 182 |
-
f"Pika task {task_id} succeeded but no video data found in response."
|
| 183 |
-
)
|
| 184 |
-
logging.error(error_msg)
|
| 185 |
-
raise PikaApiError(error_msg)
|
| 186 |
-
|
| 187 |
-
video_url = str(final_response.url)
|
| 188 |
-
logging.info("Pika task %s succeeded. Video URL: %s", task_id, video_url)
|
| 189 |
-
|
| 190 |
-
return (download_url_to_video_output(video_url),)
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
class PikaImageToVideoV2_2(PikaNodeBase):
|
| 194 |
-
"""Pika 2.2 Image to Video Node."""
|
| 195 |
-
|
| 196 |
-
@classmethod
|
| 197 |
-
def INPUT_TYPES(cls):
|
| 198 |
-
return {
|
| 199 |
-
"required": {
|
| 200 |
-
"image": (
|
| 201 |
-
IO.IMAGE,
|
| 202 |
-
{"tooltip": "The image to convert to video"},
|
| 203 |
-
),
|
| 204 |
-
**cls.get_base_inputs_types(PikaBodyGenerate22I2vGenerate22I2vPost),
|
| 205 |
-
},
|
| 206 |
-
"hidden": {
|
| 207 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 208 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 209 |
-
"unique_id": "UNIQUE_ID",
|
| 210 |
-
},
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
DESCRIPTION = "Sends an image and prompt to the Pika API v2.2 to generate a video."
|
| 214 |
-
|
| 215 |
-
def api_call(
|
| 216 |
-
self,
|
| 217 |
-
image: torch.Tensor,
|
| 218 |
-
prompt_text: str,
|
| 219 |
-
negative_prompt: str,
|
| 220 |
-
seed: int,
|
| 221 |
-
resolution: str,
|
| 222 |
-
duration: int,
|
| 223 |
-
unique_id: str,
|
| 224 |
-
**kwargs,
|
| 225 |
-
) -> tuple[VideoFromFile]:
|
| 226 |
-
# Convert image to BytesIO
|
| 227 |
-
image_bytes_io = tensor_to_bytesio(image)
|
| 228 |
-
image_bytes_io.seek(0)
|
| 229 |
-
|
| 230 |
-
pika_files = {"image": ("image.png", image_bytes_io, "image/png")}
|
| 231 |
-
|
| 232 |
-
# Prepare non-file data
|
| 233 |
-
pika_request_data = PikaBodyGenerate22I2vGenerate22I2vPost(
|
| 234 |
-
promptText=prompt_text,
|
| 235 |
-
negativePrompt=negative_prompt,
|
| 236 |
-
seed=seed,
|
| 237 |
-
resolution=resolution,
|
| 238 |
-
duration=duration,
|
| 239 |
-
)
|
| 240 |
-
|
| 241 |
-
initial_operation = SynchronousOperation(
|
| 242 |
-
endpoint=ApiEndpoint(
|
| 243 |
-
path=PATH_IMAGE_TO_VIDEO,
|
| 244 |
-
method=HttpMethod.POST,
|
| 245 |
-
request_model=PikaBodyGenerate22I2vGenerate22I2vPost,
|
| 246 |
-
response_model=PikaGenerateResponse,
|
| 247 |
-
),
|
| 248 |
-
request=pika_request_data,
|
| 249 |
-
files=pika_files,
|
| 250 |
-
content_type="multipart/form-data",
|
| 251 |
-
auth_kwargs=kwargs,
|
| 252 |
-
)
|
| 253 |
-
|
| 254 |
-
return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
class PikaTextToVideoNodeV2_2(PikaNodeBase):
|
| 258 |
-
"""Pika Text2Video v2.2 Node."""
|
| 259 |
-
|
| 260 |
-
@classmethod
|
| 261 |
-
def INPUT_TYPES(cls):
|
| 262 |
-
return {
|
| 263 |
-
"required": {
|
| 264 |
-
**cls.get_base_inputs_types(PikaBodyGenerate22T2vGenerate22T2vPost),
|
| 265 |
-
"aspect_ratio": model_field_to_node_input(
|
| 266 |
-
IO.FLOAT,
|
| 267 |
-
PikaBodyGenerate22T2vGenerate22T2vPost,
|
| 268 |
-
"aspectRatio",
|
| 269 |
-
step=0.001,
|
| 270 |
-
min=0.4,
|
| 271 |
-
max=2.5,
|
| 272 |
-
default=1.7777777777777777,
|
| 273 |
-
),
|
| 274 |
-
},
|
| 275 |
-
"hidden": {
|
| 276 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 277 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 278 |
-
"unique_id": "UNIQUE_ID",
|
| 279 |
-
},
|
| 280 |
-
}
|
| 281 |
-
|
| 282 |
-
DESCRIPTION = "Sends a text prompt to the Pika API v2.2 to generate a video."
|
| 283 |
-
|
| 284 |
-
def api_call(
|
| 285 |
-
self,
|
| 286 |
-
prompt_text: str,
|
| 287 |
-
negative_prompt: str,
|
| 288 |
-
seed: int,
|
| 289 |
-
resolution: str,
|
| 290 |
-
duration: int,
|
| 291 |
-
aspect_ratio: float,
|
| 292 |
-
unique_id: str,
|
| 293 |
-
**kwargs,
|
| 294 |
-
) -> tuple[VideoFromFile]:
|
| 295 |
-
initial_operation = SynchronousOperation(
|
| 296 |
-
endpoint=ApiEndpoint(
|
| 297 |
-
path=PATH_TEXT_TO_VIDEO,
|
| 298 |
-
method=HttpMethod.POST,
|
| 299 |
-
request_model=PikaBodyGenerate22T2vGenerate22T2vPost,
|
| 300 |
-
response_model=PikaGenerateResponse,
|
| 301 |
-
),
|
| 302 |
-
request=PikaBodyGenerate22T2vGenerate22T2vPost(
|
| 303 |
-
promptText=prompt_text,
|
| 304 |
-
negativePrompt=negative_prompt,
|
| 305 |
-
seed=seed,
|
| 306 |
-
resolution=resolution,
|
| 307 |
-
duration=duration,
|
| 308 |
-
aspectRatio=aspect_ratio,
|
| 309 |
-
),
|
| 310 |
-
auth_kwargs=kwargs,
|
| 311 |
-
content_type="application/x-www-form-urlencoded",
|
| 312 |
-
)
|
| 313 |
-
|
| 314 |
-
return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
class PikaScenesV2_2(PikaNodeBase):
|
| 318 |
-
"""PikaScenes v2.2 Node."""
|
| 319 |
-
|
| 320 |
-
@classmethod
|
| 321 |
-
def INPUT_TYPES(cls):
|
| 322 |
-
image_ingredient_input = (
|
| 323 |
-
IO.IMAGE,
|
| 324 |
-
{"tooltip": "Image that will be used as ingredient to create a video."},
|
| 325 |
-
)
|
| 326 |
-
return {
|
| 327 |
-
"required": {
|
| 328 |
-
**cls.get_base_inputs_types(
|
| 329 |
-
PikaBodyGenerate22C2vGenerate22PikascenesPost,
|
| 330 |
-
),
|
| 331 |
-
"ingredients_mode": model_field_to_node_input(
|
| 332 |
-
IO.COMBO,
|
| 333 |
-
PikaBodyGenerate22C2vGenerate22PikascenesPost,
|
| 334 |
-
"ingredientsMode",
|
| 335 |
-
enum_type=IngredientsMode,
|
| 336 |
-
default="creative",
|
| 337 |
-
),
|
| 338 |
-
"aspect_ratio": model_field_to_node_input(
|
| 339 |
-
IO.FLOAT,
|
| 340 |
-
PikaBodyGenerate22C2vGenerate22PikascenesPost,
|
| 341 |
-
"aspectRatio",
|
| 342 |
-
step=0.001,
|
| 343 |
-
min=0.4,
|
| 344 |
-
max=2.5,
|
| 345 |
-
default=1.7777777777777777,
|
| 346 |
-
),
|
| 347 |
-
},
|
| 348 |
-
"optional": {
|
| 349 |
-
"image_ingredient_1": image_ingredient_input,
|
| 350 |
-
"image_ingredient_2": image_ingredient_input,
|
| 351 |
-
"image_ingredient_3": image_ingredient_input,
|
| 352 |
-
"image_ingredient_4": image_ingredient_input,
|
| 353 |
-
"image_ingredient_5": image_ingredient_input,
|
| 354 |
-
},
|
| 355 |
-
"hidden": {
|
| 356 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 357 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 358 |
-
"unique_id": "UNIQUE_ID",
|
| 359 |
-
},
|
| 360 |
-
}
|
| 361 |
-
|
| 362 |
-
DESCRIPTION = "Combine your images to create a video with the objects in them. Upload multiple images as ingredients and generate a high-quality video that incorporates all of them."
|
| 363 |
-
|
| 364 |
-
def api_call(
|
| 365 |
-
self,
|
| 366 |
-
prompt_text: str,
|
| 367 |
-
negative_prompt: str,
|
| 368 |
-
seed: int,
|
| 369 |
-
resolution: str,
|
| 370 |
-
duration: int,
|
| 371 |
-
ingredients_mode: str,
|
| 372 |
-
aspect_ratio: float,
|
| 373 |
-
unique_id: str,
|
| 374 |
-
image_ingredient_1: Optional[torch.Tensor] = None,
|
| 375 |
-
image_ingredient_2: Optional[torch.Tensor] = None,
|
| 376 |
-
image_ingredient_3: Optional[torch.Tensor] = None,
|
| 377 |
-
image_ingredient_4: Optional[torch.Tensor] = None,
|
| 378 |
-
image_ingredient_5: Optional[torch.Tensor] = None,
|
| 379 |
-
**kwargs,
|
| 380 |
-
) -> tuple[VideoFromFile]:
|
| 381 |
-
# Convert all passed images to BytesIO
|
| 382 |
-
all_image_bytes_io = []
|
| 383 |
-
for image in [
|
| 384 |
-
image_ingredient_1,
|
| 385 |
-
image_ingredient_2,
|
| 386 |
-
image_ingredient_3,
|
| 387 |
-
image_ingredient_4,
|
| 388 |
-
image_ingredient_5,
|
| 389 |
-
]:
|
| 390 |
-
if image is not None:
|
| 391 |
-
image_bytes_io = tensor_to_bytesio(image)
|
| 392 |
-
image_bytes_io.seek(0)
|
| 393 |
-
all_image_bytes_io.append(image_bytes_io)
|
| 394 |
-
|
| 395 |
-
pika_files = [
|
| 396 |
-
("images", (f"image_{i}.png", image_bytes_io, "image/png"))
|
| 397 |
-
for i, image_bytes_io in enumerate(all_image_bytes_io)
|
| 398 |
-
]
|
| 399 |
-
|
| 400 |
-
pika_request_data = PikaBodyGenerate22C2vGenerate22PikascenesPost(
|
| 401 |
-
ingredientsMode=ingredients_mode,
|
| 402 |
-
promptText=prompt_text,
|
| 403 |
-
negativePrompt=negative_prompt,
|
| 404 |
-
seed=seed,
|
| 405 |
-
resolution=resolution,
|
| 406 |
-
duration=duration,
|
| 407 |
-
aspectRatio=aspect_ratio,
|
| 408 |
-
)
|
| 409 |
-
|
| 410 |
-
initial_operation = SynchronousOperation(
|
| 411 |
-
endpoint=ApiEndpoint(
|
| 412 |
-
path=PATH_PIKASCENES,
|
| 413 |
-
method=HttpMethod.POST,
|
| 414 |
-
request_model=PikaBodyGenerate22C2vGenerate22PikascenesPost,
|
| 415 |
-
response_model=PikaGenerateResponse,
|
| 416 |
-
),
|
| 417 |
-
request=pika_request_data,
|
| 418 |
-
files=pika_files,
|
| 419 |
-
content_type="multipart/form-data",
|
| 420 |
-
auth_kwargs=kwargs,
|
| 421 |
-
)
|
| 422 |
-
|
| 423 |
-
return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
class PikAdditionsNode(PikaNodeBase):
|
| 427 |
-
"""Pika Pikadditions Node. Add an image into a video."""
|
| 428 |
-
|
| 429 |
-
@classmethod
|
| 430 |
-
def INPUT_TYPES(cls):
|
| 431 |
-
return {
|
| 432 |
-
"required": {
|
| 433 |
-
"video": (IO.VIDEO, {"tooltip": "The video to add an image to."}),
|
| 434 |
-
"image": (IO.IMAGE, {"tooltip": "The image to add to the video."}),
|
| 435 |
-
"prompt_text": model_field_to_node_input(
|
| 436 |
-
IO.STRING,
|
| 437 |
-
PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
|
| 438 |
-
"promptText",
|
| 439 |
-
multiline=True,
|
| 440 |
-
),
|
| 441 |
-
"negative_prompt": model_field_to_node_input(
|
| 442 |
-
IO.STRING,
|
| 443 |
-
PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
|
| 444 |
-
"negativePrompt",
|
| 445 |
-
multiline=True,
|
| 446 |
-
),
|
| 447 |
-
"seed": model_field_to_node_input(
|
| 448 |
-
IO.INT,
|
| 449 |
-
PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
|
| 450 |
-
"seed",
|
| 451 |
-
min=0,
|
| 452 |
-
max=0xFFFFFFFF,
|
| 453 |
-
control_after_generate=True,
|
| 454 |
-
),
|
| 455 |
-
},
|
| 456 |
-
"hidden": {
|
| 457 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 458 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 459 |
-
"unique_id": "UNIQUE_ID",
|
| 460 |
-
},
|
| 461 |
-
}
|
| 462 |
-
|
| 463 |
-
DESCRIPTION = "Add any object or image into your video. Upload a video and specify what you'd like to add to create a seamlessly integrated result."
|
| 464 |
-
|
| 465 |
-
def api_call(
|
| 466 |
-
self,
|
| 467 |
-
video: VideoInput,
|
| 468 |
-
image: torch.Tensor,
|
| 469 |
-
prompt_text: str,
|
| 470 |
-
negative_prompt: str,
|
| 471 |
-
seed: int,
|
| 472 |
-
unique_id: str,
|
| 473 |
-
**kwargs,
|
| 474 |
-
) -> tuple[VideoFromFile]:
|
| 475 |
-
# Convert video to BytesIO
|
| 476 |
-
video_bytes_io = io.BytesIO()
|
| 477 |
-
video.save_to(video_bytes_io, format=VideoContainer.MP4, codec=VideoCodec.H264)
|
| 478 |
-
video_bytes_io.seek(0)
|
| 479 |
-
|
| 480 |
-
# Convert image to BytesIO
|
| 481 |
-
image_bytes_io = tensor_to_bytesio(image)
|
| 482 |
-
image_bytes_io.seek(0)
|
| 483 |
-
|
| 484 |
-
pika_files = [
|
| 485 |
-
("video", ("video.mp4", video_bytes_io, "video/mp4")),
|
| 486 |
-
("image", ("image.png", image_bytes_io, "image/png")),
|
| 487 |
-
]
|
| 488 |
-
|
| 489 |
-
# Prepare non-file data
|
| 490 |
-
pika_request_data = PikaBodyGeneratePikadditionsGeneratePikadditionsPost(
|
| 491 |
-
promptText=prompt_text,
|
| 492 |
-
negativePrompt=negative_prompt,
|
| 493 |
-
seed=seed,
|
| 494 |
-
)
|
| 495 |
-
|
| 496 |
-
initial_operation = SynchronousOperation(
|
| 497 |
-
endpoint=ApiEndpoint(
|
| 498 |
-
path=PATH_PIKADDITIONS,
|
| 499 |
-
method=HttpMethod.POST,
|
| 500 |
-
request_model=PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
|
| 501 |
-
response_model=PikaGenerateResponse,
|
| 502 |
-
),
|
| 503 |
-
request=pika_request_data,
|
| 504 |
-
files=pika_files,
|
| 505 |
-
content_type="multipart/form-data",
|
| 506 |
-
auth_kwargs=kwargs,
|
| 507 |
-
)
|
| 508 |
-
|
| 509 |
-
return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
class PikaSwapsNode(PikaNodeBase):
|
| 513 |
-
"""Pika Pikaswaps Node."""
|
| 514 |
-
|
| 515 |
-
@classmethod
|
| 516 |
-
def INPUT_TYPES(cls):
|
| 517 |
-
return {
|
| 518 |
-
"required": {
|
| 519 |
-
"video": (IO.VIDEO, {"tooltip": "The video to swap an object in."}),
|
| 520 |
-
"image": (
|
| 521 |
-
IO.IMAGE,
|
| 522 |
-
{
|
| 523 |
-
"tooltip": "The image used to replace the masked object in the video."
|
| 524 |
-
},
|
| 525 |
-
),
|
| 526 |
-
"mask": (
|
| 527 |
-
IO.MASK,
|
| 528 |
-
{"tooltip": "Use the mask to define areas in the video to replace"},
|
| 529 |
-
),
|
| 530 |
-
"prompt_text": model_field_to_node_input(
|
| 531 |
-
IO.STRING,
|
| 532 |
-
PikaBodyGeneratePikaswapsGeneratePikaswapsPost,
|
| 533 |
-
"promptText",
|
| 534 |
-
multiline=True,
|
| 535 |
-
),
|
| 536 |
-
"negative_prompt": model_field_to_node_input(
|
| 537 |
-
IO.STRING,
|
| 538 |
-
PikaBodyGeneratePikaswapsGeneratePikaswapsPost,
|
| 539 |
-
"negativePrompt",
|
| 540 |
-
multiline=True,
|
| 541 |
-
),
|
| 542 |
-
"seed": model_field_to_node_input(
|
| 543 |
-
IO.INT,
|
| 544 |
-
PikaBodyGeneratePikaswapsGeneratePikaswapsPost,
|
| 545 |
-
"seed",
|
| 546 |
-
min=0,
|
| 547 |
-
max=0xFFFFFFFF,
|
| 548 |
-
control_after_generate=True,
|
| 549 |
-
),
|
| 550 |
-
},
|
| 551 |
-
"hidden": {
|
| 552 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 553 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 554 |
-
"unique_id": "UNIQUE_ID",
|
| 555 |
-
},
|
| 556 |
-
}
|
| 557 |
-
|
| 558 |
-
DESCRIPTION = "Swap out any object or region of your video with a new image or object. Define areas to replace either with a mask or coordinates."
|
| 559 |
-
RETURN_TYPES = ("VIDEO",)
|
| 560 |
-
|
| 561 |
-
def api_call(
|
| 562 |
-
self,
|
| 563 |
-
video: VideoInput,
|
| 564 |
-
image: torch.Tensor,
|
| 565 |
-
mask: torch.Tensor,
|
| 566 |
-
prompt_text: str,
|
| 567 |
-
negative_prompt: str,
|
| 568 |
-
seed: int,
|
| 569 |
-
unique_id: str,
|
| 570 |
-
**kwargs,
|
| 571 |
-
) -> tuple[VideoFromFile]:
|
| 572 |
-
# Convert video to BytesIO
|
| 573 |
-
video_bytes_io = io.BytesIO()
|
| 574 |
-
video.save_to(video_bytes_io, format=VideoContainer.MP4, codec=VideoCodec.H264)
|
| 575 |
-
video_bytes_io.seek(0)
|
| 576 |
-
|
| 577 |
-
# Convert mask to binary mask with three channels
|
| 578 |
-
mask = torch.round(mask)
|
| 579 |
-
mask = mask.repeat(1, 3, 1, 1)
|
| 580 |
-
|
| 581 |
-
# Convert 3-channel binary mask to BytesIO
|
| 582 |
-
mask_bytes_io = io.BytesIO()
|
| 583 |
-
mask_bytes_io.write(mask.numpy().astype(np.uint8))
|
| 584 |
-
mask_bytes_io.seek(0)
|
| 585 |
-
|
| 586 |
-
# Convert image to BytesIO
|
| 587 |
-
image_bytes_io = tensor_to_bytesio(image)
|
| 588 |
-
image_bytes_io.seek(0)
|
| 589 |
-
|
| 590 |
-
pika_files = [
|
| 591 |
-
("video", ("video.mp4", video_bytes_io, "video/mp4")),
|
| 592 |
-
("image", ("image.png", image_bytes_io, "image/png")),
|
| 593 |
-
("modifyRegionMask", ("mask.png", mask_bytes_io, "image/png")),
|
| 594 |
-
]
|
| 595 |
-
|
| 596 |
-
# Prepare non-file data
|
| 597 |
-
pika_request_data = PikaBodyGeneratePikaswapsGeneratePikaswapsPost(
|
| 598 |
-
promptText=prompt_text,
|
| 599 |
-
negativePrompt=negative_prompt,
|
| 600 |
-
seed=seed,
|
| 601 |
-
)
|
| 602 |
-
|
| 603 |
-
initial_operation = SynchronousOperation(
|
| 604 |
-
endpoint=ApiEndpoint(
|
| 605 |
-
path=PATH_PIKADDITIONS,
|
| 606 |
-
method=HttpMethod.POST,
|
| 607 |
-
request_model=PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
|
| 608 |
-
response_model=PikaGenerateResponse,
|
| 609 |
-
),
|
| 610 |
-
request=pika_request_data,
|
| 611 |
-
files=pika_files,
|
| 612 |
-
content_type="multipart/form-data",
|
| 613 |
-
auth_kwargs=kwargs,
|
| 614 |
-
)
|
| 615 |
-
|
| 616 |
-
return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
class PikaffectsNode(PikaNodeBase):
|
| 620 |
-
"""Pika Pikaffects Node."""
|
| 621 |
-
|
| 622 |
-
@classmethod
|
| 623 |
-
def INPUT_TYPES(cls):
|
| 624 |
-
return {
|
| 625 |
-
"required": {
|
| 626 |
-
"image": (
|
| 627 |
-
IO.IMAGE,
|
| 628 |
-
{"tooltip": "The reference image to apply the Pikaffect to."},
|
| 629 |
-
),
|
| 630 |
-
"pikaffect": model_field_to_node_input(
|
| 631 |
-
IO.COMBO,
|
| 632 |
-
PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
|
| 633 |
-
"pikaffect",
|
| 634 |
-
enum_type=Pikaffect,
|
| 635 |
-
default="Cake-ify",
|
| 636 |
-
),
|
| 637 |
-
"prompt_text": model_field_to_node_input(
|
| 638 |
-
IO.STRING,
|
| 639 |
-
PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
|
| 640 |
-
"promptText",
|
| 641 |
-
multiline=True,
|
| 642 |
-
),
|
| 643 |
-
"negative_prompt": model_field_to_node_input(
|
| 644 |
-
IO.STRING,
|
| 645 |
-
PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
|
| 646 |
-
"negativePrompt",
|
| 647 |
-
multiline=True,
|
| 648 |
-
),
|
| 649 |
-
"seed": model_field_to_node_input(
|
| 650 |
-
IO.INT,
|
| 651 |
-
PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
|
| 652 |
-
"seed",
|
| 653 |
-
min=0,
|
| 654 |
-
max=0xFFFFFFFF,
|
| 655 |
-
control_after_generate=True,
|
| 656 |
-
),
|
| 657 |
-
},
|
| 658 |
-
"hidden": {
|
| 659 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 660 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 661 |
-
"unique_id": "UNIQUE_ID",
|
| 662 |
-
},
|
| 663 |
-
}
|
| 664 |
-
|
| 665 |
-
DESCRIPTION = "Generate a video with a specific Pikaffect. Supported Pikaffects: Cake-ify, Crumble, Crush, Decapitate, Deflate, Dissolve, Explode, Eye-pop, Inflate, Levitate, Melt, Peel, Poke, Squish, Ta-da, Tear"
|
| 666 |
-
|
| 667 |
-
def api_call(
|
| 668 |
-
self,
|
| 669 |
-
image: torch.Tensor,
|
| 670 |
-
pikaffect: str,
|
| 671 |
-
prompt_text: str,
|
| 672 |
-
negative_prompt: str,
|
| 673 |
-
seed: int,
|
| 674 |
-
unique_id: str,
|
| 675 |
-
**kwargs,
|
| 676 |
-
) -> tuple[VideoFromFile]:
|
| 677 |
-
|
| 678 |
-
initial_operation = SynchronousOperation(
|
| 679 |
-
endpoint=ApiEndpoint(
|
| 680 |
-
path=PATH_PIKAFFECTS,
|
| 681 |
-
method=HttpMethod.POST,
|
| 682 |
-
request_model=PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
|
| 683 |
-
response_model=PikaGenerateResponse,
|
| 684 |
-
),
|
| 685 |
-
request=PikaBodyGeneratePikaffectsGeneratePikaffectsPost(
|
| 686 |
-
pikaffect=pikaffect,
|
| 687 |
-
promptText=prompt_text,
|
| 688 |
-
negativePrompt=negative_prompt,
|
| 689 |
-
seed=seed,
|
| 690 |
-
),
|
| 691 |
-
files={"image": ("image.png", tensor_to_bytesio(image), "image/png")},
|
| 692 |
-
content_type="multipart/form-data",
|
| 693 |
-
auth_kwargs=kwargs,
|
| 694 |
-
)
|
| 695 |
-
|
| 696 |
-
return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
class PikaStartEndFrameNode2_2(PikaNodeBase):
|
| 700 |
-
"""PikaFrames v2.2 Node."""
|
| 701 |
-
|
| 702 |
-
@classmethod
|
| 703 |
-
def INPUT_TYPES(cls):
|
| 704 |
-
return {
|
| 705 |
-
"required": {
|
| 706 |
-
"image_start": (IO.IMAGE, {"tooltip": "The first image to combine."}),
|
| 707 |
-
"image_end": (IO.IMAGE, {"tooltip": "The last image to combine."}),
|
| 708 |
-
**cls.get_base_inputs_types(
|
| 709 |
-
PikaBodyGenerate22KeyframeGenerate22PikaframesPost
|
| 710 |
-
),
|
| 711 |
-
},
|
| 712 |
-
"hidden": {
|
| 713 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 714 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 715 |
-
"unique_id": "UNIQUE_ID",
|
| 716 |
-
},
|
| 717 |
-
}
|
| 718 |
-
|
| 719 |
-
DESCRIPTION = "Generate a video by combining your first and last frame. Upload two images to define the start and end points, and let the AI create a smooth transition between them."
|
| 720 |
-
|
| 721 |
-
def api_call(
|
| 722 |
-
self,
|
| 723 |
-
image_start: torch.Tensor,
|
| 724 |
-
image_end: torch.Tensor,
|
| 725 |
-
prompt_text: str,
|
| 726 |
-
negative_prompt: str,
|
| 727 |
-
seed: int,
|
| 728 |
-
resolution: str,
|
| 729 |
-
duration: int,
|
| 730 |
-
unique_id: str,
|
| 731 |
-
**kwargs,
|
| 732 |
-
) -> tuple[VideoFromFile]:
|
| 733 |
-
|
| 734 |
-
pika_files = [
|
| 735 |
-
(
|
| 736 |
-
"keyFrames",
|
| 737 |
-
("image_start.png", tensor_to_bytesio(image_start), "image/png"),
|
| 738 |
-
),
|
| 739 |
-
("keyFrames", ("image_end.png", tensor_to_bytesio(image_end), "image/png")),
|
| 740 |
-
]
|
| 741 |
-
|
| 742 |
-
initial_operation = SynchronousOperation(
|
| 743 |
-
endpoint=ApiEndpoint(
|
| 744 |
-
path=PATH_PIKAFRAMES,
|
| 745 |
-
method=HttpMethod.POST,
|
| 746 |
-
request_model=PikaBodyGenerate22KeyframeGenerate22PikaframesPost,
|
| 747 |
-
response_model=PikaGenerateResponse,
|
| 748 |
-
),
|
| 749 |
-
request=PikaBodyGenerate22KeyframeGenerate22PikaframesPost(
|
| 750 |
-
promptText=prompt_text,
|
| 751 |
-
negativePrompt=negative_prompt,
|
| 752 |
-
seed=seed,
|
| 753 |
-
resolution=resolution,
|
| 754 |
-
duration=duration,
|
| 755 |
-
),
|
| 756 |
-
files=pika_files,
|
| 757 |
-
content_type="multipart/form-data",
|
| 758 |
-
auth_kwargs=kwargs,
|
| 759 |
-
)
|
| 760 |
-
|
| 761 |
-
return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
NODE_CLASS_MAPPINGS = {
|
| 765 |
-
"PikaImageToVideoNode2_2": PikaImageToVideoV2_2,
|
| 766 |
-
"PikaTextToVideoNode2_2": PikaTextToVideoNodeV2_2,
|
| 767 |
-
"PikaScenesV2_2": PikaScenesV2_2,
|
| 768 |
-
"Pikadditions": PikAdditionsNode,
|
| 769 |
-
"Pikaswaps": PikaSwapsNode,
|
| 770 |
-
"Pikaffects": PikaffectsNode,
|
| 771 |
-
"PikaStartEndFrameNode2_2": PikaStartEndFrameNode2_2,
|
| 772 |
-
}
|
| 773 |
-
|
| 774 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 775 |
-
"PikaImageToVideoNode2_2": "Pika Image to Video",
|
| 776 |
-
"PikaTextToVideoNode2_2": "Pika Text to Video",
|
| 777 |
-
"PikaScenesV2_2": "Pika Scenes (Video Image Composition)",
|
| 778 |
-
"Pikadditions": "Pikadditions (Video Object Insertion)",
|
| 779 |
-
"Pikaswaps": "Pika Swaps (Video Object Replacement)",
|
| 780 |
-
"Pikaffects": "Pikaffects (Video Effects)",
|
| 781 |
-
"PikaStartEndFrameNode2_2": "Pika Start and End Frame to Video",
|
| 782 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_pixverse.py
DELETED
|
@@ -1,525 +0,0 @@
|
|
| 1 |
-
from inspect import cleandoc
|
| 2 |
-
from typing import Optional
|
| 3 |
-
from comfy_api_nodes.apis.pixverse_api import (
|
| 4 |
-
PixverseTextVideoRequest,
|
| 5 |
-
PixverseImageVideoRequest,
|
| 6 |
-
PixverseTransitionVideoRequest,
|
| 7 |
-
PixverseImageUploadResponse,
|
| 8 |
-
PixverseVideoResponse,
|
| 9 |
-
PixverseGenerationStatusResponse,
|
| 10 |
-
PixverseAspectRatio,
|
| 11 |
-
PixverseQuality,
|
| 12 |
-
PixverseDuration,
|
| 13 |
-
PixverseMotionMode,
|
| 14 |
-
PixverseStatus,
|
| 15 |
-
PixverseIO,
|
| 16 |
-
pixverse_templates,
|
| 17 |
-
)
|
| 18 |
-
from comfy_api_nodes.apis.client import (
|
| 19 |
-
ApiEndpoint,
|
| 20 |
-
HttpMethod,
|
| 21 |
-
SynchronousOperation,
|
| 22 |
-
PollingOperation,
|
| 23 |
-
EmptyRequest,
|
| 24 |
-
)
|
| 25 |
-
from comfy_api_nodes.apinode_utils import (
|
| 26 |
-
tensor_to_bytesio,
|
| 27 |
-
validate_string,
|
| 28 |
-
)
|
| 29 |
-
from comfy.comfy_types.node_typing import IO, ComfyNodeABC
|
| 30 |
-
from comfy_api.input_impl import VideoFromFile
|
| 31 |
-
|
| 32 |
-
import torch
|
| 33 |
-
import requests
|
| 34 |
-
from io import BytesIO
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
AVERAGE_DURATION_T2V = 32
|
| 38 |
-
AVERAGE_DURATION_I2V = 30
|
| 39 |
-
AVERAGE_DURATION_T2T = 52
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def get_video_url_from_response(
|
| 43 |
-
response: PixverseGenerationStatusResponse,
|
| 44 |
-
) -> Optional[str]:
|
| 45 |
-
if response.Resp is None or response.Resp.url is None:
|
| 46 |
-
return None
|
| 47 |
-
return str(response.Resp.url)
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def upload_image_to_pixverse(image: torch.Tensor, auth_kwargs=None):
|
| 51 |
-
# first, upload image to Pixverse and get image id to use in actual generation call
|
| 52 |
-
files = {"image": tensor_to_bytesio(image)}
|
| 53 |
-
operation = SynchronousOperation(
|
| 54 |
-
endpoint=ApiEndpoint(
|
| 55 |
-
path="/proxy/pixverse/image/upload",
|
| 56 |
-
method=HttpMethod.POST,
|
| 57 |
-
request_model=EmptyRequest,
|
| 58 |
-
response_model=PixverseImageUploadResponse,
|
| 59 |
-
),
|
| 60 |
-
request=EmptyRequest(),
|
| 61 |
-
files=files,
|
| 62 |
-
content_type="multipart/form-data",
|
| 63 |
-
auth_kwargs=auth_kwargs,
|
| 64 |
-
)
|
| 65 |
-
response_upload: PixverseImageUploadResponse = operation.execute()
|
| 66 |
-
|
| 67 |
-
if response_upload.Resp is None:
|
| 68 |
-
raise Exception(
|
| 69 |
-
f"PixVerse image upload request failed: '{response_upload.ErrMsg}'"
|
| 70 |
-
)
|
| 71 |
-
|
| 72 |
-
return response_upload.Resp.img_id
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
class PixverseTemplateNode:
|
| 76 |
-
"""
|
| 77 |
-
Select template for PixVerse Video generation.
|
| 78 |
-
"""
|
| 79 |
-
|
| 80 |
-
RETURN_TYPES = (PixverseIO.TEMPLATE,)
|
| 81 |
-
RETURN_NAMES = ("pixverse_template",)
|
| 82 |
-
FUNCTION = "create_template"
|
| 83 |
-
CATEGORY = "api node/video/PixVerse"
|
| 84 |
-
|
| 85 |
-
@classmethod
|
| 86 |
-
def INPUT_TYPES(s):
|
| 87 |
-
return {
|
| 88 |
-
"required": {
|
| 89 |
-
"template": (list(pixverse_templates.keys()),),
|
| 90 |
-
}
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
def create_template(self, template: str):
|
| 94 |
-
template_id = pixverse_templates.get(template, None)
|
| 95 |
-
if template_id is None:
|
| 96 |
-
raise Exception(f"Template '{template}' is not recognized.")
|
| 97 |
-
# just return the integer
|
| 98 |
-
return (template_id,)
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
class PixverseTextToVideoNode(ComfyNodeABC):
|
| 102 |
-
"""
|
| 103 |
-
Generates videos based on prompt and output_size.
|
| 104 |
-
"""
|
| 105 |
-
|
| 106 |
-
RETURN_TYPES = (IO.VIDEO,)
|
| 107 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 108 |
-
FUNCTION = "api_call"
|
| 109 |
-
API_NODE = True
|
| 110 |
-
CATEGORY = "api node/video/PixVerse"
|
| 111 |
-
|
| 112 |
-
@classmethod
|
| 113 |
-
def INPUT_TYPES(s):
|
| 114 |
-
return {
|
| 115 |
-
"required": {
|
| 116 |
-
"prompt": (
|
| 117 |
-
IO.STRING,
|
| 118 |
-
{
|
| 119 |
-
"multiline": True,
|
| 120 |
-
"default": "",
|
| 121 |
-
"tooltip": "Prompt for the video generation",
|
| 122 |
-
},
|
| 123 |
-
),
|
| 124 |
-
"aspect_ratio": ([ratio.value for ratio in PixverseAspectRatio],),
|
| 125 |
-
"quality": (
|
| 126 |
-
[resolution.value for resolution in PixverseQuality],
|
| 127 |
-
{
|
| 128 |
-
"default": PixverseQuality.res_540p,
|
| 129 |
-
},
|
| 130 |
-
),
|
| 131 |
-
"duration_seconds": ([dur.value for dur in PixverseDuration],),
|
| 132 |
-
"motion_mode": ([mode.value for mode in PixverseMotionMode],),
|
| 133 |
-
"seed": (
|
| 134 |
-
IO.INT,
|
| 135 |
-
{
|
| 136 |
-
"default": 0,
|
| 137 |
-
"min": 0,
|
| 138 |
-
"max": 2147483647,
|
| 139 |
-
"control_after_generate": True,
|
| 140 |
-
"tooltip": "Seed for video generation.",
|
| 141 |
-
},
|
| 142 |
-
),
|
| 143 |
-
},
|
| 144 |
-
"optional": {
|
| 145 |
-
"negative_prompt": (
|
| 146 |
-
IO.STRING,
|
| 147 |
-
{
|
| 148 |
-
"default": "",
|
| 149 |
-
"forceInput": True,
|
| 150 |
-
"tooltip": "An optional text description of undesired elements on an image.",
|
| 151 |
-
},
|
| 152 |
-
),
|
| 153 |
-
"pixverse_template": (
|
| 154 |
-
PixverseIO.TEMPLATE,
|
| 155 |
-
{
|
| 156 |
-
"tooltip": "An optional template to influence style of generation, created by the PixVerse Template node."
|
| 157 |
-
},
|
| 158 |
-
),
|
| 159 |
-
},
|
| 160 |
-
"hidden": {
|
| 161 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 162 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 163 |
-
"unique_id": "UNIQUE_ID",
|
| 164 |
-
},
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
def api_call(
|
| 168 |
-
self,
|
| 169 |
-
prompt: str,
|
| 170 |
-
aspect_ratio: str,
|
| 171 |
-
quality: str,
|
| 172 |
-
duration_seconds: int,
|
| 173 |
-
motion_mode: str,
|
| 174 |
-
seed,
|
| 175 |
-
negative_prompt: str = None,
|
| 176 |
-
pixverse_template: int = None,
|
| 177 |
-
unique_id: Optional[str] = None,
|
| 178 |
-
**kwargs,
|
| 179 |
-
):
|
| 180 |
-
validate_string(prompt, strip_whitespace=False)
|
| 181 |
-
# 1080p is limited to 5 seconds duration
|
| 182 |
-
# only normal motion_mode supported for 1080p or for non-5 second duration
|
| 183 |
-
if quality == PixverseQuality.res_1080p:
|
| 184 |
-
motion_mode = PixverseMotionMode.normal
|
| 185 |
-
duration_seconds = PixverseDuration.dur_5
|
| 186 |
-
elif duration_seconds != PixverseDuration.dur_5:
|
| 187 |
-
motion_mode = PixverseMotionMode.normal
|
| 188 |
-
|
| 189 |
-
operation = SynchronousOperation(
|
| 190 |
-
endpoint=ApiEndpoint(
|
| 191 |
-
path="/proxy/pixverse/video/text/generate",
|
| 192 |
-
method=HttpMethod.POST,
|
| 193 |
-
request_model=PixverseTextVideoRequest,
|
| 194 |
-
response_model=PixverseVideoResponse,
|
| 195 |
-
),
|
| 196 |
-
request=PixverseTextVideoRequest(
|
| 197 |
-
prompt=prompt,
|
| 198 |
-
aspect_ratio=aspect_ratio,
|
| 199 |
-
quality=quality,
|
| 200 |
-
duration=duration_seconds,
|
| 201 |
-
motion_mode=motion_mode,
|
| 202 |
-
negative_prompt=negative_prompt if negative_prompt else None,
|
| 203 |
-
template_id=pixverse_template,
|
| 204 |
-
seed=seed,
|
| 205 |
-
),
|
| 206 |
-
auth_kwargs=kwargs,
|
| 207 |
-
)
|
| 208 |
-
response_api = operation.execute()
|
| 209 |
-
|
| 210 |
-
if response_api.Resp is None:
|
| 211 |
-
raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'")
|
| 212 |
-
|
| 213 |
-
operation = PollingOperation(
|
| 214 |
-
poll_endpoint=ApiEndpoint(
|
| 215 |
-
path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}",
|
| 216 |
-
method=HttpMethod.GET,
|
| 217 |
-
request_model=EmptyRequest,
|
| 218 |
-
response_model=PixverseGenerationStatusResponse,
|
| 219 |
-
),
|
| 220 |
-
completed_statuses=[PixverseStatus.successful],
|
| 221 |
-
failed_statuses=[
|
| 222 |
-
PixverseStatus.contents_moderation,
|
| 223 |
-
PixverseStatus.failed,
|
| 224 |
-
PixverseStatus.deleted,
|
| 225 |
-
],
|
| 226 |
-
status_extractor=lambda x: x.Resp.status,
|
| 227 |
-
auth_kwargs=kwargs,
|
| 228 |
-
node_id=unique_id,
|
| 229 |
-
result_url_extractor=get_video_url_from_response,
|
| 230 |
-
estimated_duration=AVERAGE_DURATION_T2V,
|
| 231 |
-
)
|
| 232 |
-
response_poll = operation.execute()
|
| 233 |
-
|
| 234 |
-
vid_response = requests.get(response_poll.Resp.url)
|
| 235 |
-
|
| 236 |
-
return (VideoFromFile(BytesIO(vid_response.content)),)
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
class PixverseImageToVideoNode(ComfyNodeABC):
|
| 240 |
-
"""
|
| 241 |
-
Generates videos based on prompt and output_size.
|
| 242 |
-
"""
|
| 243 |
-
|
| 244 |
-
RETURN_TYPES = (IO.VIDEO,)
|
| 245 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 246 |
-
FUNCTION = "api_call"
|
| 247 |
-
API_NODE = True
|
| 248 |
-
CATEGORY = "api node/video/PixVerse"
|
| 249 |
-
|
| 250 |
-
@classmethod
|
| 251 |
-
def INPUT_TYPES(s):
|
| 252 |
-
return {
|
| 253 |
-
"required": {
|
| 254 |
-
"image": (IO.IMAGE,),
|
| 255 |
-
"prompt": (
|
| 256 |
-
IO.STRING,
|
| 257 |
-
{
|
| 258 |
-
"multiline": True,
|
| 259 |
-
"default": "",
|
| 260 |
-
"tooltip": "Prompt for the video generation",
|
| 261 |
-
},
|
| 262 |
-
),
|
| 263 |
-
"quality": (
|
| 264 |
-
[resolution.value for resolution in PixverseQuality],
|
| 265 |
-
{
|
| 266 |
-
"default": PixverseQuality.res_540p,
|
| 267 |
-
},
|
| 268 |
-
),
|
| 269 |
-
"duration_seconds": ([dur.value for dur in PixverseDuration],),
|
| 270 |
-
"motion_mode": ([mode.value for mode in PixverseMotionMode],),
|
| 271 |
-
"seed": (
|
| 272 |
-
IO.INT,
|
| 273 |
-
{
|
| 274 |
-
"default": 0,
|
| 275 |
-
"min": 0,
|
| 276 |
-
"max": 2147483647,
|
| 277 |
-
"control_after_generate": True,
|
| 278 |
-
"tooltip": "Seed for video generation.",
|
| 279 |
-
},
|
| 280 |
-
),
|
| 281 |
-
},
|
| 282 |
-
"optional": {
|
| 283 |
-
"negative_prompt": (
|
| 284 |
-
IO.STRING,
|
| 285 |
-
{
|
| 286 |
-
"default": "",
|
| 287 |
-
"forceInput": True,
|
| 288 |
-
"tooltip": "An optional text description of undesired elements on an image.",
|
| 289 |
-
},
|
| 290 |
-
),
|
| 291 |
-
"pixverse_template": (
|
| 292 |
-
PixverseIO.TEMPLATE,
|
| 293 |
-
{
|
| 294 |
-
"tooltip": "An optional template to influence style of generation, created by the PixVerse Template node."
|
| 295 |
-
},
|
| 296 |
-
),
|
| 297 |
-
},
|
| 298 |
-
"hidden": {
|
| 299 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 300 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 301 |
-
"unique_id": "UNIQUE_ID",
|
| 302 |
-
},
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
-
def api_call(
|
| 306 |
-
self,
|
| 307 |
-
image: torch.Tensor,
|
| 308 |
-
prompt: str,
|
| 309 |
-
quality: str,
|
| 310 |
-
duration_seconds: int,
|
| 311 |
-
motion_mode: str,
|
| 312 |
-
seed,
|
| 313 |
-
negative_prompt: str = None,
|
| 314 |
-
pixverse_template: int = None,
|
| 315 |
-
unique_id: Optional[str] = None,
|
| 316 |
-
**kwargs,
|
| 317 |
-
):
|
| 318 |
-
validate_string(prompt, strip_whitespace=False)
|
| 319 |
-
img_id = upload_image_to_pixverse(image, auth_kwargs=kwargs)
|
| 320 |
-
|
| 321 |
-
# 1080p is limited to 5 seconds duration
|
| 322 |
-
# only normal motion_mode supported for 1080p or for non-5 second duration
|
| 323 |
-
if quality == PixverseQuality.res_1080p:
|
| 324 |
-
motion_mode = PixverseMotionMode.normal
|
| 325 |
-
duration_seconds = PixverseDuration.dur_5
|
| 326 |
-
elif duration_seconds != PixverseDuration.dur_5:
|
| 327 |
-
motion_mode = PixverseMotionMode.normal
|
| 328 |
-
|
| 329 |
-
operation = SynchronousOperation(
|
| 330 |
-
endpoint=ApiEndpoint(
|
| 331 |
-
path="/proxy/pixverse/video/img/generate",
|
| 332 |
-
method=HttpMethod.POST,
|
| 333 |
-
request_model=PixverseImageVideoRequest,
|
| 334 |
-
response_model=PixverseVideoResponse,
|
| 335 |
-
),
|
| 336 |
-
request=PixverseImageVideoRequest(
|
| 337 |
-
img_id=img_id,
|
| 338 |
-
prompt=prompt,
|
| 339 |
-
quality=quality,
|
| 340 |
-
duration=duration_seconds,
|
| 341 |
-
motion_mode=motion_mode,
|
| 342 |
-
negative_prompt=negative_prompt if negative_prompt else None,
|
| 343 |
-
template_id=pixverse_template,
|
| 344 |
-
seed=seed,
|
| 345 |
-
),
|
| 346 |
-
auth_kwargs=kwargs,
|
| 347 |
-
)
|
| 348 |
-
response_api = operation.execute()
|
| 349 |
-
|
| 350 |
-
if response_api.Resp is None:
|
| 351 |
-
raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'")
|
| 352 |
-
|
| 353 |
-
operation = PollingOperation(
|
| 354 |
-
poll_endpoint=ApiEndpoint(
|
| 355 |
-
path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}",
|
| 356 |
-
method=HttpMethod.GET,
|
| 357 |
-
request_model=EmptyRequest,
|
| 358 |
-
response_model=PixverseGenerationStatusResponse,
|
| 359 |
-
),
|
| 360 |
-
completed_statuses=[PixverseStatus.successful],
|
| 361 |
-
failed_statuses=[
|
| 362 |
-
PixverseStatus.contents_moderation,
|
| 363 |
-
PixverseStatus.failed,
|
| 364 |
-
PixverseStatus.deleted,
|
| 365 |
-
],
|
| 366 |
-
status_extractor=lambda x: x.Resp.status,
|
| 367 |
-
auth_kwargs=kwargs,
|
| 368 |
-
node_id=unique_id,
|
| 369 |
-
result_url_extractor=get_video_url_from_response,
|
| 370 |
-
estimated_duration=AVERAGE_DURATION_I2V,
|
| 371 |
-
)
|
| 372 |
-
response_poll = operation.execute()
|
| 373 |
-
|
| 374 |
-
vid_response = requests.get(response_poll.Resp.url)
|
| 375 |
-
return (VideoFromFile(BytesIO(vid_response.content)),)
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
class PixverseTransitionVideoNode(ComfyNodeABC):
|
| 379 |
-
"""
|
| 380 |
-
Generates videos based on prompt and output_size.
|
| 381 |
-
"""
|
| 382 |
-
|
| 383 |
-
RETURN_TYPES = (IO.VIDEO,)
|
| 384 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 385 |
-
FUNCTION = "api_call"
|
| 386 |
-
API_NODE = True
|
| 387 |
-
CATEGORY = "api node/video/PixVerse"
|
| 388 |
-
|
| 389 |
-
@classmethod
|
| 390 |
-
def INPUT_TYPES(s):
|
| 391 |
-
return {
|
| 392 |
-
"required": {
|
| 393 |
-
"first_frame": (IO.IMAGE,),
|
| 394 |
-
"last_frame": (IO.IMAGE,),
|
| 395 |
-
"prompt": (
|
| 396 |
-
IO.STRING,
|
| 397 |
-
{
|
| 398 |
-
"multiline": True,
|
| 399 |
-
"default": "",
|
| 400 |
-
"tooltip": "Prompt for the video generation",
|
| 401 |
-
},
|
| 402 |
-
),
|
| 403 |
-
"quality": (
|
| 404 |
-
[resolution.value for resolution in PixverseQuality],
|
| 405 |
-
{
|
| 406 |
-
"default": PixverseQuality.res_540p,
|
| 407 |
-
},
|
| 408 |
-
),
|
| 409 |
-
"duration_seconds": ([dur.value for dur in PixverseDuration],),
|
| 410 |
-
"motion_mode": ([mode.value for mode in PixverseMotionMode],),
|
| 411 |
-
"seed": (
|
| 412 |
-
IO.INT,
|
| 413 |
-
{
|
| 414 |
-
"default": 0,
|
| 415 |
-
"min": 0,
|
| 416 |
-
"max": 2147483647,
|
| 417 |
-
"control_after_generate": True,
|
| 418 |
-
"tooltip": "Seed for video generation.",
|
| 419 |
-
},
|
| 420 |
-
),
|
| 421 |
-
},
|
| 422 |
-
"optional": {
|
| 423 |
-
"negative_prompt": (
|
| 424 |
-
IO.STRING,
|
| 425 |
-
{
|
| 426 |
-
"default": "",
|
| 427 |
-
"forceInput": True,
|
| 428 |
-
"tooltip": "An optional text description of undesired elements on an image.",
|
| 429 |
-
},
|
| 430 |
-
),
|
| 431 |
-
},
|
| 432 |
-
"hidden": {
|
| 433 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 434 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 435 |
-
"unique_id": "UNIQUE_ID",
|
| 436 |
-
},
|
| 437 |
-
}
|
| 438 |
-
|
| 439 |
-
def api_call(
|
| 440 |
-
self,
|
| 441 |
-
first_frame: torch.Tensor,
|
| 442 |
-
last_frame: torch.Tensor,
|
| 443 |
-
prompt: str,
|
| 444 |
-
quality: str,
|
| 445 |
-
duration_seconds: int,
|
| 446 |
-
motion_mode: str,
|
| 447 |
-
seed,
|
| 448 |
-
negative_prompt: str = None,
|
| 449 |
-
unique_id: Optional[str] = None,
|
| 450 |
-
**kwargs,
|
| 451 |
-
):
|
| 452 |
-
validate_string(prompt, strip_whitespace=False)
|
| 453 |
-
first_frame_id = upload_image_to_pixverse(first_frame, auth_kwargs=kwargs)
|
| 454 |
-
last_frame_id = upload_image_to_pixverse(last_frame, auth_kwargs=kwargs)
|
| 455 |
-
|
| 456 |
-
# 1080p is limited to 5 seconds duration
|
| 457 |
-
# only normal motion_mode supported for 1080p or for non-5 second duration
|
| 458 |
-
if quality == PixverseQuality.res_1080p:
|
| 459 |
-
motion_mode = PixverseMotionMode.normal
|
| 460 |
-
duration_seconds = PixverseDuration.dur_5
|
| 461 |
-
elif duration_seconds != PixverseDuration.dur_5:
|
| 462 |
-
motion_mode = PixverseMotionMode.normal
|
| 463 |
-
|
| 464 |
-
operation = SynchronousOperation(
|
| 465 |
-
endpoint=ApiEndpoint(
|
| 466 |
-
path="/proxy/pixverse/video/transition/generate",
|
| 467 |
-
method=HttpMethod.POST,
|
| 468 |
-
request_model=PixverseTransitionVideoRequest,
|
| 469 |
-
response_model=PixverseVideoResponse,
|
| 470 |
-
),
|
| 471 |
-
request=PixverseTransitionVideoRequest(
|
| 472 |
-
first_frame_img=first_frame_id,
|
| 473 |
-
last_frame_img=last_frame_id,
|
| 474 |
-
prompt=prompt,
|
| 475 |
-
quality=quality,
|
| 476 |
-
duration=duration_seconds,
|
| 477 |
-
motion_mode=motion_mode,
|
| 478 |
-
negative_prompt=negative_prompt if negative_prompt else None,
|
| 479 |
-
seed=seed,
|
| 480 |
-
),
|
| 481 |
-
auth_kwargs=kwargs,
|
| 482 |
-
)
|
| 483 |
-
response_api = operation.execute()
|
| 484 |
-
|
| 485 |
-
if response_api.Resp is None:
|
| 486 |
-
raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'")
|
| 487 |
-
|
| 488 |
-
operation = PollingOperation(
|
| 489 |
-
poll_endpoint=ApiEndpoint(
|
| 490 |
-
path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}",
|
| 491 |
-
method=HttpMethod.GET,
|
| 492 |
-
request_model=EmptyRequest,
|
| 493 |
-
response_model=PixverseGenerationStatusResponse,
|
| 494 |
-
),
|
| 495 |
-
completed_statuses=[PixverseStatus.successful],
|
| 496 |
-
failed_statuses=[
|
| 497 |
-
PixverseStatus.contents_moderation,
|
| 498 |
-
PixverseStatus.failed,
|
| 499 |
-
PixverseStatus.deleted,
|
| 500 |
-
],
|
| 501 |
-
status_extractor=lambda x: x.Resp.status,
|
| 502 |
-
auth_kwargs=kwargs,
|
| 503 |
-
node_id=unique_id,
|
| 504 |
-
result_url_extractor=get_video_url_from_response,
|
| 505 |
-
estimated_duration=AVERAGE_DURATION_T2V,
|
| 506 |
-
)
|
| 507 |
-
response_poll = operation.execute()
|
| 508 |
-
|
| 509 |
-
vid_response = requests.get(response_poll.Resp.url)
|
| 510 |
-
return (VideoFromFile(BytesIO(vid_response.content)),)
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
NODE_CLASS_MAPPINGS = {
|
| 514 |
-
"PixverseTextToVideoNode": PixverseTextToVideoNode,
|
| 515 |
-
"PixverseImageToVideoNode": PixverseImageToVideoNode,
|
| 516 |
-
"PixverseTransitionVideoNode": PixverseTransitionVideoNode,
|
| 517 |
-
"PixverseTemplateNode": PixverseTemplateNode,
|
| 518 |
-
}
|
| 519 |
-
|
| 520 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 521 |
-
"PixverseTextToVideoNode": "PixVerse Text to Video",
|
| 522 |
-
"PixverseImageToVideoNode": "PixVerse Image to Video",
|
| 523 |
-
"PixverseTransitionVideoNode": "PixVerse Transition Video",
|
| 524 |
-
"PixverseTemplateNode": "PixVerse Template",
|
| 525 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_recraft.py
DELETED
|
@@ -1,1138 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
from inspect import cleandoc
|
| 3 |
-
from typing import Optional
|
| 4 |
-
from comfy.utils import ProgressBar
|
| 5 |
-
from comfy_extras.nodes_images import SVG # Added
|
| 6 |
-
from comfy.comfy_types.node_typing import IO
|
| 7 |
-
from comfy_api_nodes.apis.recraft_api import (
|
| 8 |
-
RecraftImageGenerationRequest,
|
| 9 |
-
RecraftImageGenerationResponse,
|
| 10 |
-
RecraftImageSize,
|
| 11 |
-
RecraftModel,
|
| 12 |
-
RecraftStyle,
|
| 13 |
-
RecraftStyleV3,
|
| 14 |
-
RecraftColor,
|
| 15 |
-
RecraftColorChain,
|
| 16 |
-
RecraftControls,
|
| 17 |
-
RecraftIO,
|
| 18 |
-
get_v3_substyles,
|
| 19 |
-
)
|
| 20 |
-
from comfy_api_nodes.apis.client import (
|
| 21 |
-
ApiEndpoint,
|
| 22 |
-
HttpMethod,
|
| 23 |
-
SynchronousOperation,
|
| 24 |
-
EmptyRequest,
|
| 25 |
-
)
|
| 26 |
-
from comfy_api_nodes.apinode_utils import (
|
| 27 |
-
bytesio_to_image_tensor,
|
| 28 |
-
download_url_to_bytesio,
|
| 29 |
-
tensor_to_bytesio,
|
| 30 |
-
resize_mask_to_image,
|
| 31 |
-
validate_string,
|
| 32 |
-
)
|
| 33 |
-
from server import PromptServer
|
| 34 |
-
|
| 35 |
-
import torch
|
| 36 |
-
from io import BytesIO
|
| 37 |
-
from PIL import UnidentifiedImageError
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def handle_recraft_file_request(
|
| 41 |
-
image: torch.Tensor,
|
| 42 |
-
path: str,
|
| 43 |
-
mask: torch.Tensor=None,
|
| 44 |
-
total_pixels=4096*4096,
|
| 45 |
-
timeout=1024,
|
| 46 |
-
request=None,
|
| 47 |
-
auth_kwargs: dict[str,str] = None,
|
| 48 |
-
) -> list[BytesIO]:
|
| 49 |
-
"""
|
| 50 |
-
Handle sending common Recraft file-only request to get back file bytes.
|
| 51 |
-
"""
|
| 52 |
-
if request is None:
|
| 53 |
-
request = EmptyRequest()
|
| 54 |
-
|
| 55 |
-
files = {
|
| 56 |
-
'image': tensor_to_bytesio(image, total_pixels=total_pixels).read()
|
| 57 |
-
}
|
| 58 |
-
if mask is not None:
|
| 59 |
-
files['mask'] = tensor_to_bytesio(mask, total_pixels=total_pixels).read()
|
| 60 |
-
|
| 61 |
-
operation = SynchronousOperation(
|
| 62 |
-
endpoint=ApiEndpoint(
|
| 63 |
-
path=path,
|
| 64 |
-
method=HttpMethod.POST,
|
| 65 |
-
request_model=type(request),
|
| 66 |
-
response_model=RecraftImageGenerationResponse,
|
| 67 |
-
),
|
| 68 |
-
request=request,
|
| 69 |
-
files=files,
|
| 70 |
-
content_type="multipart/form-data",
|
| 71 |
-
auth_kwargs=auth_kwargs,
|
| 72 |
-
multipart_parser=recraft_multipart_parser,
|
| 73 |
-
)
|
| 74 |
-
response: RecraftImageGenerationResponse = operation.execute()
|
| 75 |
-
all_bytesio = []
|
| 76 |
-
if response.image is not None:
|
| 77 |
-
all_bytesio.append(download_url_to_bytesio(response.image.url, timeout=timeout))
|
| 78 |
-
else:
|
| 79 |
-
for data in response.data:
|
| 80 |
-
all_bytesio.append(download_url_to_bytesio(data.url, timeout=timeout))
|
| 81 |
-
|
| 82 |
-
return all_bytesio
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def recraft_multipart_parser(data, parent_key=None, formatter: callable=None, converted_to_check: list[list]=None, is_list=False) -> dict:
|
| 86 |
-
"""
|
| 87 |
-
Formats data such that multipart/form-data will work with requests library
|
| 88 |
-
when both files and data are present.
|
| 89 |
-
|
| 90 |
-
The OpenAI client that Recraft uses has a bizarre way of serializing lists:
|
| 91 |
-
|
| 92 |
-
It does NOT keep track of indeces of each list, so for background_color, that must be serialized as:
|
| 93 |
-
'background_color[rgb][]' = [0, 0, 255]
|
| 94 |
-
where the array is assigned to a key that has '[]' at the end, to signal it's an array.
|
| 95 |
-
|
| 96 |
-
This has the consequence of nested lists having the exact same key, forcing arrays to merge; all colors inputs fall under the same key:
|
| 97 |
-
if 1 color -> 'controls[colors][][rgb][]' = [0, 0, 255]
|
| 98 |
-
if 2 colors -> 'controls[colors][][rgb][]' = [0, 0, 255, 255, 0, 0]
|
| 99 |
-
if 3 colors -> 'controls[colors][][rgb][]' = [0, 0, 255, 255, 0, 0, 0, 255, 0]
|
| 100 |
-
etc.
|
| 101 |
-
Whoever made this serialization up at OpenAI added the constraint that lists must be of uniform length on objects of same 'type'.
|
| 102 |
-
"""
|
| 103 |
-
# Modification of a function that handled a different type of multipart parsing, big ups:
|
| 104 |
-
# https://gist.github.com/kazqvaizer/4cebebe5db654a414132809f9f88067b
|
| 105 |
-
|
| 106 |
-
def handle_converted_lists(data, parent_key, lists_to_check=tuple[list]):
|
| 107 |
-
# if list already exists exists, just extend list with data
|
| 108 |
-
for check_list in lists_to_check:
|
| 109 |
-
for conv_tuple in check_list:
|
| 110 |
-
if conv_tuple[0] == parent_key and type(conv_tuple[1]) is list:
|
| 111 |
-
conv_tuple[1].append(formatter(data))
|
| 112 |
-
return True
|
| 113 |
-
return False
|
| 114 |
-
|
| 115 |
-
if converted_to_check is None:
|
| 116 |
-
converted_to_check = []
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
if formatter is None:
|
| 120 |
-
formatter = lambda v: v # Multipart representation of value
|
| 121 |
-
|
| 122 |
-
if type(data) is not dict:
|
| 123 |
-
# if list already exists exists, just extend list with data
|
| 124 |
-
added = handle_converted_lists(data, parent_key, converted_to_check)
|
| 125 |
-
if added:
|
| 126 |
-
return {}
|
| 127 |
-
# otherwise if is_list, create new list with data
|
| 128 |
-
if is_list:
|
| 129 |
-
return {parent_key: [formatter(data)]}
|
| 130 |
-
# return new key with data
|
| 131 |
-
return {parent_key: formatter(data)}
|
| 132 |
-
|
| 133 |
-
converted = []
|
| 134 |
-
next_check = [converted]
|
| 135 |
-
next_check.extend(converted_to_check)
|
| 136 |
-
|
| 137 |
-
for key, value in data.items():
|
| 138 |
-
current_key = key if parent_key is None else f"{parent_key}[{key}]"
|
| 139 |
-
if type(value) is dict:
|
| 140 |
-
converted.extend(recraft_multipart_parser(value, current_key, formatter, next_check).items())
|
| 141 |
-
elif type(value) is list:
|
| 142 |
-
for ind, list_value in enumerate(value):
|
| 143 |
-
iter_key = f"{current_key}[]"
|
| 144 |
-
converted.extend(recraft_multipart_parser(list_value, iter_key, formatter, next_check, is_list=True).items())
|
| 145 |
-
else:
|
| 146 |
-
converted.append((current_key, formatter(value)))
|
| 147 |
-
|
| 148 |
-
return dict(converted)
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
class handle_recraft_image_output:
|
| 152 |
-
"""
|
| 153 |
-
Catch an exception related to receiving SVG data instead of image, when Infinite Style Library style_id is in use.
|
| 154 |
-
"""
|
| 155 |
-
def __init__(self):
|
| 156 |
-
pass
|
| 157 |
-
|
| 158 |
-
def __enter__(self):
|
| 159 |
-
pass
|
| 160 |
-
|
| 161 |
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 162 |
-
if exc_type is not None and exc_type is UnidentifiedImageError:
|
| 163 |
-
raise Exception("Received output data was not an image; likely an SVG. If you used style_id, make sure it is not a Vector art style.")
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
class RecraftColorRGBNode:
|
| 167 |
-
"""
|
| 168 |
-
Create Recraft Color by choosing specific RGB values.
|
| 169 |
-
"""
|
| 170 |
-
|
| 171 |
-
RETURN_TYPES = (RecraftIO.COLOR,)
|
| 172 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 173 |
-
RETURN_NAMES = ("recraft_color",)
|
| 174 |
-
FUNCTION = "create_color"
|
| 175 |
-
CATEGORY = "api node/image/Recraft"
|
| 176 |
-
|
| 177 |
-
@classmethod
|
| 178 |
-
def INPUT_TYPES(s):
|
| 179 |
-
return {
|
| 180 |
-
"required": {
|
| 181 |
-
"r": (IO.INT, {
|
| 182 |
-
"default": 0,
|
| 183 |
-
"min": 0,
|
| 184 |
-
"max": 255,
|
| 185 |
-
"tooltip": "Red value of color."
|
| 186 |
-
}),
|
| 187 |
-
"g": (IO.INT, {
|
| 188 |
-
"default": 0,
|
| 189 |
-
"min": 0,
|
| 190 |
-
"max": 255,
|
| 191 |
-
"tooltip": "Green value of color."
|
| 192 |
-
}),
|
| 193 |
-
"b": (IO.INT, {
|
| 194 |
-
"default": 0,
|
| 195 |
-
"min": 0,
|
| 196 |
-
"max": 255,
|
| 197 |
-
"tooltip": "Blue value of color."
|
| 198 |
-
}),
|
| 199 |
-
},
|
| 200 |
-
"optional": {
|
| 201 |
-
"recraft_color": (RecraftIO.COLOR,),
|
| 202 |
-
}
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
def create_color(self, r: int, g: int, b: int, recraft_color: RecraftColorChain=None):
|
| 206 |
-
recraft_color = recraft_color.clone() if recraft_color else RecraftColorChain()
|
| 207 |
-
recraft_color.add(RecraftColor(r, g, b))
|
| 208 |
-
return (recraft_color, )
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
class RecraftControlsNode:
|
| 212 |
-
"""
|
| 213 |
-
Create Recraft Controls for customizing Recraft generation.
|
| 214 |
-
"""
|
| 215 |
-
|
| 216 |
-
RETURN_TYPES = (RecraftIO.CONTROLS,)
|
| 217 |
-
RETURN_NAMES = ("recraft_controls",)
|
| 218 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 219 |
-
FUNCTION = "create_controls"
|
| 220 |
-
CATEGORY = "api node/image/Recraft"
|
| 221 |
-
|
| 222 |
-
@classmethod
|
| 223 |
-
def INPUT_TYPES(s):
|
| 224 |
-
return {
|
| 225 |
-
"required": {
|
| 226 |
-
},
|
| 227 |
-
"optional": {
|
| 228 |
-
"colors": (RecraftIO.COLOR,),
|
| 229 |
-
"background_color": (RecraftIO.COLOR,),
|
| 230 |
-
}
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
def create_controls(self, colors: RecraftColorChain=None, background_color: RecraftColorChain=None):
|
| 234 |
-
return (RecraftControls(colors=colors, background_color=background_color), )
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
class RecraftStyleV3RealisticImageNode:
|
| 238 |
-
"""
|
| 239 |
-
Select realistic_image style and optional substyle.
|
| 240 |
-
"""
|
| 241 |
-
|
| 242 |
-
RETURN_TYPES = (RecraftIO.STYLEV3,)
|
| 243 |
-
RETURN_NAMES = ("recraft_style",)
|
| 244 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 245 |
-
FUNCTION = "create_style"
|
| 246 |
-
CATEGORY = "api node/image/Recraft"
|
| 247 |
-
|
| 248 |
-
RECRAFT_STYLE = RecraftStyleV3.realistic_image
|
| 249 |
-
|
| 250 |
-
@classmethod
|
| 251 |
-
def INPUT_TYPES(s):
|
| 252 |
-
return {
|
| 253 |
-
"required": {
|
| 254 |
-
"substyle": (get_v3_substyles(s.RECRAFT_STYLE),),
|
| 255 |
-
}
|
| 256 |
-
}
|
| 257 |
-
|
| 258 |
-
def create_style(self, substyle: str):
|
| 259 |
-
if substyle == "None":
|
| 260 |
-
substyle = None
|
| 261 |
-
return (RecraftStyle(self.RECRAFT_STYLE, substyle),)
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
class RecraftStyleV3DigitalIllustrationNode(RecraftStyleV3RealisticImageNode):
|
| 265 |
-
"""
|
| 266 |
-
Select digital_illustration style and optional substyle.
|
| 267 |
-
"""
|
| 268 |
-
|
| 269 |
-
RECRAFT_STYLE = RecraftStyleV3.digital_illustration
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
class RecraftStyleV3VectorIllustrationNode(RecraftStyleV3RealisticImageNode):
|
| 273 |
-
"""
|
| 274 |
-
Select vector_illustration style and optional substyle.
|
| 275 |
-
"""
|
| 276 |
-
|
| 277 |
-
RECRAFT_STYLE = RecraftStyleV3.vector_illustration
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
class RecraftStyleV3LogoRasterNode(RecraftStyleV3RealisticImageNode):
|
| 281 |
-
"""
|
| 282 |
-
Select vector_illustration style and optional substyle.
|
| 283 |
-
"""
|
| 284 |
-
|
| 285 |
-
@classmethod
|
| 286 |
-
def INPUT_TYPES(s):
|
| 287 |
-
return {
|
| 288 |
-
"required": {
|
| 289 |
-
"substyle": (get_v3_substyles(s.RECRAFT_STYLE, include_none=False),),
|
| 290 |
-
}
|
| 291 |
-
}
|
| 292 |
-
|
| 293 |
-
RECRAFT_STYLE = RecraftStyleV3.logo_raster
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
class RecraftStyleInfiniteStyleLibrary:
|
| 297 |
-
"""
|
| 298 |
-
Select style based on preexisting UUID from Recraft's Infinite Style Library.
|
| 299 |
-
"""
|
| 300 |
-
|
| 301 |
-
RETURN_TYPES = (RecraftIO.STYLEV3,)
|
| 302 |
-
RETURN_NAMES = ("recraft_style",)
|
| 303 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 304 |
-
FUNCTION = "create_style"
|
| 305 |
-
CATEGORY = "api node/image/Recraft"
|
| 306 |
-
|
| 307 |
-
@classmethod
|
| 308 |
-
def INPUT_TYPES(s):
|
| 309 |
-
return {
|
| 310 |
-
"required": {
|
| 311 |
-
"style_id": (IO.STRING, {
|
| 312 |
-
"default": "",
|
| 313 |
-
"tooltip": "UUID of style from Infinite Style Library.",
|
| 314 |
-
})
|
| 315 |
-
}
|
| 316 |
-
}
|
| 317 |
-
|
| 318 |
-
def create_style(self, style_id: str):
|
| 319 |
-
if not style_id:
|
| 320 |
-
raise Exception("The style_id input cannot be empty.")
|
| 321 |
-
return (RecraftStyle(style_id=style_id),)
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
class RecraftTextToImageNode:
|
| 325 |
-
"""
|
| 326 |
-
Generates images synchronously based on prompt and resolution.
|
| 327 |
-
"""
|
| 328 |
-
|
| 329 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 330 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 331 |
-
FUNCTION = "api_call"
|
| 332 |
-
API_NODE = True
|
| 333 |
-
CATEGORY = "api node/image/Recraft"
|
| 334 |
-
|
| 335 |
-
@classmethod
|
| 336 |
-
def INPUT_TYPES(s):
|
| 337 |
-
return {
|
| 338 |
-
"required": {
|
| 339 |
-
"prompt": (
|
| 340 |
-
IO.STRING,
|
| 341 |
-
{
|
| 342 |
-
"multiline": True,
|
| 343 |
-
"default": "",
|
| 344 |
-
"tooltip": "Prompt for the image generation.",
|
| 345 |
-
},
|
| 346 |
-
),
|
| 347 |
-
"size": (
|
| 348 |
-
[res.value for res in RecraftImageSize],
|
| 349 |
-
{
|
| 350 |
-
"default": RecraftImageSize.res_1024x1024,
|
| 351 |
-
"tooltip": "The size of the generated image.",
|
| 352 |
-
},
|
| 353 |
-
),
|
| 354 |
-
"n": (
|
| 355 |
-
IO.INT,
|
| 356 |
-
{
|
| 357 |
-
"default": 1,
|
| 358 |
-
"min": 1,
|
| 359 |
-
"max": 6,
|
| 360 |
-
"tooltip": "The number of images to generate.",
|
| 361 |
-
},
|
| 362 |
-
),
|
| 363 |
-
"seed": (
|
| 364 |
-
IO.INT,
|
| 365 |
-
{
|
| 366 |
-
"default": 0,
|
| 367 |
-
"min": 0,
|
| 368 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 369 |
-
"control_after_generate": True,
|
| 370 |
-
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
|
| 371 |
-
},
|
| 372 |
-
),
|
| 373 |
-
},
|
| 374 |
-
"optional": {
|
| 375 |
-
"recraft_style": (RecraftIO.STYLEV3,),
|
| 376 |
-
"negative_prompt": (
|
| 377 |
-
IO.STRING,
|
| 378 |
-
{
|
| 379 |
-
"default": "",
|
| 380 |
-
"forceInput": True,
|
| 381 |
-
"tooltip": "An optional text description of undesired elements on an image.",
|
| 382 |
-
},
|
| 383 |
-
),
|
| 384 |
-
"recraft_controls": (
|
| 385 |
-
RecraftIO.CONTROLS,
|
| 386 |
-
{
|
| 387 |
-
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
|
| 388 |
-
},
|
| 389 |
-
),
|
| 390 |
-
},
|
| 391 |
-
"hidden": {
|
| 392 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 393 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 394 |
-
"unique_id": "UNIQUE_ID",
|
| 395 |
-
},
|
| 396 |
-
}
|
| 397 |
-
|
| 398 |
-
def api_call(
|
| 399 |
-
self,
|
| 400 |
-
prompt: str,
|
| 401 |
-
size: str,
|
| 402 |
-
n: int,
|
| 403 |
-
seed,
|
| 404 |
-
recraft_style: RecraftStyle = None,
|
| 405 |
-
negative_prompt: str = None,
|
| 406 |
-
recraft_controls: RecraftControls = None,
|
| 407 |
-
unique_id: Optional[str] = None,
|
| 408 |
-
**kwargs,
|
| 409 |
-
):
|
| 410 |
-
validate_string(prompt, strip_whitespace=False, max_length=1000)
|
| 411 |
-
default_style = RecraftStyle(RecraftStyleV3.realistic_image)
|
| 412 |
-
if recraft_style is None:
|
| 413 |
-
recraft_style = default_style
|
| 414 |
-
|
| 415 |
-
controls_api = None
|
| 416 |
-
if recraft_controls:
|
| 417 |
-
controls_api = recraft_controls.create_api_model()
|
| 418 |
-
|
| 419 |
-
if not negative_prompt:
|
| 420 |
-
negative_prompt = None
|
| 421 |
-
|
| 422 |
-
operation = SynchronousOperation(
|
| 423 |
-
endpoint=ApiEndpoint(
|
| 424 |
-
path="/proxy/recraft/image_generation",
|
| 425 |
-
method=HttpMethod.POST,
|
| 426 |
-
request_model=RecraftImageGenerationRequest,
|
| 427 |
-
response_model=RecraftImageGenerationResponse,
|
| 428 |
-
),
|
| 429 |
-
request=RecraftImageGenerationRequest(
|
| 430 |
-
prompt=prompt,
|
| 431 |
-
negative_prompt=negative_prompt,
|
| 432 |
-
model=RecraftModel.recraftv3,
|
| 433 |
-
size=size,
|
| 434 |
-
n=n,
|
| 435 |
-
style=recraft_style.style,
|
| 436 |
-
substyle=recraft_style.substyle,
|
| 437 |
-
style_id=recraft_style.style_id,
|
| 438 |
-
controls=controls_api,
|
| 439 |
-
),
|
| 440 |
-
auth_kwargs=kwargs,
|
| 441 |
-
)
|
| 442 |
-
response: RecraftImageGenerationResponse = operation.execute()
|
| 443 |
-
images = []
|
| 444 |
-
urls = []
|
| 445 |
-
for data in response.data:
|
| 446 |
-
with handle_recraft_image_output():
|
| 447 |
-
if unique_id and data.url:
|
| 448 |
-
urls.append(data.url)
|
| 449 |
-
urls_string = '\n'.join(urls)
|
| 450 |
-
PromptServer.instance.send_progress_text(
|
| 451 |
-
f"Result URL: {urls_string}", unique_id
|
| 452 |
-
)
|
| 453 |
-
image = bytesio_to_image_tensor(
|
| 454 |
-
download_url_to_bytesio(data.url, timeout=1024)
|
| 455 |
-
)
|
| 456 |
-
if len(image.shape) < 4:
|
| 457 |
-
image = image.unsqueeze(0)
|
| 458 |
-
images.append(image)
|
| 459 |
-
output_image = torch.cat(images, dim=0)
|
| 460 |
-
|
| 461 |
-
return (output_image,)
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
class RecraftImageToImageNode:
|
| 465 |
-
"""
|
| 466 |
-
Modify image based on prompt and strength.
|
| 467 |
-
"""
|
| 468 |
-
|
| 469 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 470 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 471 |
-
FUNCTION = "api_call"
|
| 472 |
-
API_NODE = True
|
| 473 |
-
CATEGORY = "api node/image/Recraft"
|
| 474 |
-
|
| 475 |
-
@classmethod
|
| 476 |
-
def INPUT_TYPES(s):
|
| 477 |
-
return {
|
| 478 |
-
"required": {
|
| 479 |
-
"image": (IO.IMAGE, ),
|
| 480 |
-
"prompt": (
|
| 481 |
-
IO.STRING,
|
| 482 |
-
{
|
| 483 |
-
"multiline": True,
|
| 484 |
-
"default": "",
|
| 485 |
-
"tooltip": "Prompt for the image generation.",
|
| 486 |
-
},
|
| 487 |
-
),
|
| 488 |
-
"n": (
|
| 489 |
-
IO.INT,
|
| 490 |
-
{
|
| 491 |
-
"default": 1,
|
| 492 |
-
"min": 1,
|
| 493 |
-
"max": 6,
|
| 494 |
-
"tooltip": "The number of images to generate.",
|
| 495 |
-
},
|
| 496 |
-
),
|
| 497 |
-
"strength": (
|
| 498 |
-
IO.FLOAT,
|
| 499 |
-
{
|
| 500 |
-
"default": 0.5,
|
| 501 |
-
"min": 0.0,
|
| 502 |
-
"max": 1.0,
|
| 503 |
-
"step": 0.01,
|
| 504 |
-
"tooltip": "Defines the difference with the original image, should lie in [0, 1], where 0 means almost identical, and 1 means miserable similarity."
|
| 505 |
-
}
|
| 506 |
-
),
|
| 507 |
-
"seed": (
|
| 508 |
-
IO.INT,
|
| 509 |
-
{
|
| 510 |
-
"default": 0,
|
| 511 |
-
"min": 0,
|
| 512 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 513 |
-
"control_after_generate": True,
|
| 514 |
-
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
|
| 515 |
-
},
|
| 516 |
-
),
|
| 517 |
-
},
|
| 518 |
-
"optional": {
|
| 519 |
-
"recraft_style": (RecraftIO.STYLEV3,),
|
| 520 |
-
"negative_prompt": (
|
| 521 |
-
IO.STRING,
|
| 522 |
-
{
|
| 523 |
-
"default": "",
|
| 524 |
-
"forceInput": True,
|
| 525 |
-
"tooltip": "An optional text description of undesired elements on an image.",
|
| 526 |
-
},
|
| 527 |
-
),
|
| 528 |
-
"recraft_controls": (
|
| 529 |
-
RecraftIO.CONTROLS,
|
| 530 |
-
{
|
| 531 |
-
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
|
| 532 |
-
},
|
| 533 |
-
),
|
| 534 |
-
},
|
| 535 |
-
"hidden": {
|
| 536 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 537 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 538 |
-
},
|
| 539 |
-
}
|
| 540 |
-
|
| 541 |
-
def api_call(
|
| 542 |
-
self,
|
| 543 |
-
image: torch.Tensor,
|
| 544 |
-
prompt: str,
|
| 545 |
-
n: int,
|
| 546 |
-
strength: float,
|
| 547 |
-
seed,
|
| 548 |
-
recraft_style: RecraftStyle = None,
|
| 549 |
-
negative_prompt: str = None,
|
| 550 |
-
recraft_controls: RecraftControls = None,
|
| 551 |
-
**kwargs,
|
| 552 |
-
):
|
| 553 |
-
validate_string(prompt, strip_whitespace=False, max_length=1000)
|
| 554 |
-
default_style = RecraftStyle(RecraftStyleV3.realistic_image)
|
| 555 |
-
if recraft_style is None:
|
| 556 |
-
recraft_style = default_style
|
| 557 |
-
|
| 558 |
-
controls_api = None
|
| 559 |
-
if recraft_controls:
|
| 560 |
-
controls_api = recraft_controls.create_api_model()
|
| 561 |
-
|
| 562 |
-
if not negative_prompt:
|
| 563 |
-
negative_prompt = None
|
| 564 |
-
|
| 565 |
-
request = RecraftImageGenerationRequest(
|
| 566 |
-
prompt=prompt,
|
| 567 |
-
negative_prompt=negative_prompt,
|
| 568 |
-
model=RecraftModel.recraftv3,
|
| 569 |
-
n=n,
|
| 570 |
-
strength=round(strength, 2),
|
| 571 |
-
style=recraft_style.style,
|
| 572 |
-
substyle=recraft_style.substyle,
|
| 573 |
-
style_id=recraft_style.style_id,
|
| 574 |
-
controls=controls_api,
|
| 575 |
-
)
|
| 576 |
-
|
| 577 |
-
images = []
|
| 578 |
-
total = image.shape[0]
|
| 579 |
-
pbar = ProgressBar(total)
|
| 580 |
-
for i in range(total):
|
| 581 |
-
sub_bytes = handle_recraft_file_request(
|
| 582 |
-
image=image[i],
|
| 583 |
-
path="/proxy/recraft/images/imageToImage",
|
| 584 |
-
request=request,
|
| 585 |
-
auth_kwargs=kwargs,
|
| 586 |
-
)
|
| 587 |
-
with handle_recraft_image_output():
|
| 588 |
-
images.append(torch.cat([bytesio_to_image_tensor(x) for x in sub_bytes], dim=0))
|
| 589 |
-
pbar.update(1)
|
| 590 |
-
|
| 591 |
-
images_tensor = torch.cat(images, dim=0)
|
| 592 |
-
return (images_tensor, )
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
class RecraftImageInpaintingNode:
|
| 596 |
-
"""
|
| 597 |
-
Modify image based on prompt and mask.
|
| 598 |
-
"""
|
| 599 |
-
|
| 600 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 601 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 602 |
-
FUNCTION = "api_call"
|
| 603 |
-
API_NODE = True
|
| 604 |
-
CATEGORY = "api node/image/Recraft"
|
| 605 |
-
|
| 606 |
-
@classmethod
|
| 607 |
-
def INPUT_TYPES(s):
|
| 608 |
-
return {
|
| 609 |
-
"required": {
|
| 610 |
-
"image": (IO.IMAGE, ),
|
| 611 |
-
"mask": (IO.MASK, ),
|
| 612 |
-
"prompt": (
|
| 613 |
-
IO.STRING,
|
| 614 |
-
{
|
| 615 |
-
"multiline": True,
|
| 616 |
-
"default": "",
|
| 617 |
-
"tooltip": "Prompt for the image generation.",
|
| 618 |
-
},
|
| 619 |
-
),
|
| 620 |
-
"n": (
|
| 621 |
-
IO.INT,
|
| 622 |
-
{
|
| 623 |
-
"default": 1,
|
| 624 |
-
"min": 1,
|
| 625 |
-
"max": 6,
|
| 626 |
-
"tooltip": "The number of images to generate.",
|
| 627 |
-
},
|
| 628 |
-
),
|
| 629 |
-
"seed": (
|
| 630 |
-
IO.INT,
|
| 631 |
-
{
|
| 632 |
-
"default": 0,
|
| 633 |
-
"min": 0,
|
| 634 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 635 |
-
"control_after_generate": True,
|
| 636 |
-
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
|
| 637 |
-
},
|
| 638 |
-
),
|
| 639 |
-
},
|
| 640 |
-
"optional": {
|
| 641 |
-
"recraft_style": (RecraftIO.STYLEV3,),
|
| 642 |
-
"negative_prompt": (
|
| 643 |
-
IO.STRING,
|
| 644 |
-
{
|
| 645 |
-
"default": "",
|
| 646 |
-
"forceInput": True,
|
| 647 |
-
"tooltip": "An optional text description of undesired elements on an image.",
|
| 648 |
-
},
|
| 649 |
-
),
|
| 650 |
-
},
|
| 651 |
-
"hidden": {
|
| 652 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 653 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 654 |
-
},
|
| 655 |
-
}
|
| 656 |
-
|
| 657 |
-
def api_call(
|
| 658 |
-
self,
|
| 659 |
-
image: torch.Tensor,
|
| 660 |
-
mask: torch.Tensor,
|
| 661 |
-
prompt: str,
|
| 662 |
-
n: int,
|
| 663 |
-
seed,
|
| 664 |
-
recraft_style: RecraftStyle = None,
|
| 665 |
-
negative_prompt: str = None,
|
| 666 |
-
**kwargs,
|
| 667 |
-
):
|
| 668 |
-
validate_string(prompt, strip_whitespace=False, max_length=1000)
|
| 669 |
-
default_style = RecraftStyle(RecraftStyleV3.realistic_image)
|
| 670 |
-
if recraft_style is None:
|
| 671 |
-
recraft_style = default_style
|
| 672 |
-
|
| 673 |
-
if not negative_prompt:
|
| 674 |
-
negative_prompt = None
|
| 675 |
-
|
| 676 |
-
request = RecraftImageGenerationRequest(
|
| 677 |
-
prompt=prompt,
|
| 678 |
-
negative_prompt=negative_prompt,
|
| 679 |
-
model=RecraftModel.recraftv3,
|
| 680 |
-
n=n,
|
| 681 |
-
style=recraft_style.style,
|
| 682 |
-
substyle=recraft_style.substyle,
|
| 683 |
-
style_id=recraft_style.style_id,
|
| 684 |
-
)
|
| 685 |
-
|
| 686 |
-
# prepare mask tensor
|
| 687 |
-
mask = resize_mask_to_image(mask, image, allow_gradient=False, add_channel_dim=True)
|
| 688 |
-
|
| 689 |
-
images = []
|
| 690 |
-
total = image.shape[0]
|
| 691 |
-
pbar = ProgressBar(total)
|
| 692 |
-
for i in range(total):
|
| 693 |
-
sub_bytes = handle_recraft_file_request(
|
| 694 |
-
image=image[i],
|
| 695 |
-
mask=mask[i:i+1],
|
| 696 |
-
path="/proxy/recraft/images/inpaint",
|
| 697 |
-
request=request,
|
| 698 |
-
auth_kwargs=kwargs,
|
| 699 |
-
)
|
| 700 |
-
with handle_recraft_image_output():
|
| 701 |
-
images.append(torch.cat([bytesio_to_image_tensor(x) for x in sub_bytes], dim=0))
|
| 702 |
-
pbar.update(1)
|
| 703 |
-
|
| 704 |
-
images_tensor = torch.cat(images, dim=0)
|
| 705 |
-
return (images_tensor, )
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
class RecraftTextToVectorNode:
|
| 709 |
-
"""
|
| 710 |
-
Generates SVG synchronously based on prompt and resolution.
|
| 711 |
-
"""
|
| 712 |
-
|
| 713 |
-
RETURN_TYPES = ("SVG",) # Changed
|
| 714 |
-
DESCRIPTION = cleandoc(__doc__ or "") if 'cleandoc' in globals() else __doc__ # Keep cleandoc if other nodes use it
|
| 715 |
-
FUNCTION = "api_call"
|
| 716 |
-
API_NODE = True
|
| 717 |
-
CATEGORY = "api node/image/Recraft"
|
| 718 |
-
|
| 719 |
-
@classmethod
|
| 720 |
-
def INPUT_TYPES(s):
|
| 721 |
-
return {
|
| 722 |
-
"required": {
|
| 723 |
-
"prompt": (
|
| 724 |
-
IO.STRING,
|
| 725 |
-
{
|
| 726 |
-
"multiline": True,
|
| 727 |
-
"default": "",
|
| 728 |
-
"tooltip": "Prompt for the image generation.",
|
| 729 |
-
},
|
| 730 |
-
),
|
| 731 |
-
"substyle": (get_v3_substyles(RecraftStyleV3.vector_illustration),),
|
| 732 |
-
"size": (
|
| 733 |
-
[res.value for res in RecraftImageSize],
|
| 734 |
-
{
|
| 735 |
-
"default": RecraftImageSize.res_1024x1024,
|
| 736 |
-
"tooltip": "The size of the generated image.",
|
| 737 |
-
},
|
| 738 |
-
),
|
| 739 |
-
"n": (
|
| 740 |
-
IO.INT,
|
| 741 |
-
{
|
| 742 |
-
"default": 1,
|
| 743 |
-
"min": 1,
|
| 744 |
-
"max": 6,
|
| 745 |
-
"tooltip": "The number of images to generate.",
|
| 746 |
-
},
|
| 747 |
-
),
|
| 748 |
-
"seed": (
|
| 749 |
-
IO.INT,
|
| 750 |
-
{
|
| 751 |
-
"default": 0,
|
| 752 |
-
"min": 0,
|
| 753 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 754 |
-
"control_after_generate": True,
|
| 755 |
-
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
|
| 756 |
-
},
|
| 757 |
-
),
|
| 758 |
-
},
|
| 759 |
-
"optional": {
|
| 760 |
-
"negative_prompt": (
|
| 761 |
-
IO.STRING,
|
| 762 |
-
{
|
| 763 |
-
"default": "",
|
| 764 |
-
"forceInput": True,
|
| 765 |
-
"tooltip": "An optional text description of undesired elements on an image.",
|
| 766 |
-
},
|
| 767 |
-
),
|
| 768 |
-
"recraft_controls": (
|
| 769 |
-
RecraftIO.CONTROLS,
|
| 770 |
-
{
|
| 771 |
-
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
|
| 772 |
-
},
|
| 773 |
-
),
|
| 774 |
-
},
|
| 775 |
-
"hidden": {
|
| 776 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 777 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 778 |
-
"unique_id": "UNIQUE_ID",
|
| 779 |
-
},
|
| 780 |
-
}
|
| 781 |
-
|
| 782 |
-
def api_call(
|
| 783 |
-
self,
|
| 784 |
-
prompt: str,
|
| 785 |
-
substyle: str,
|
| 786 |
-
size: str,
|
| 787 |
-
n: int,
|
| 788 |
-
seed,
|
| 789 |
-
negative_prompt: str = None,
|
| 790 |
-
recraft_controls: RecraftControls = None,
|
| 791 |
-
unique_id: Optional[str] = None,
|
| 792 |
-
**kwargs,
|
| 793 |
-
):
|
| 794 |
-
validate_string(prompt, strip_whitespace=False, max_length=1000)
|
| 795 |
-
# create RecraftStyle so strings will be formatted properly (i.e. "None" will become None)
|
| 796 |
-
recraft_style = RecraftStyle(RecraftStyleV3.vector_illustration, substyle=substyle)
|
| 797 |
-
|
| 798 |
-
controls_api = None
|
| 799 |
-
if recraft_controls:
|
| 800 |
-
controls_api = recraft_controls.create_api_model()
|
| 801 |
-
|
| 802 |
-
if not negative_prompt:
|
| 803 |
-
negative_prompt = None
|
| 804 |
-
|
| 805 |
-
operation = SynchronousOperation(
|
| 806 |
-
endpoint=ApiEndpoint(
|
| 807 |
-
path="/proxy/recraft/image_generation",
|
| 808 |
-
method=HttpMethod.POST,
|
| 809 |
-
request_model=RecraftImageGenerationRequest,
|
| 810 |
-
response_model=RecraftImageGenerationResponse,
|
| 811 |
-
),
|
| 812 |
-
request=RecraftImageGenerationRequest(
|
| 813 |
-
prompt=prompt,
|
| 814 |
-
negative_prompt=negative_prompt,
|
| 815 |
-
model=RecraftModel.recraftv3,
|
| 816 |
-
size=size,
|
| 817 |
-
n=n,
|
| 818 |
-
style=recraft_style.style,
|
| 819 |
-
substyle=recraft_style.substyle,
|
| 820 |
-
controls=controls_api,
|
| 821 |
-
),
|
| 822 |
-
auth_kwargs=kwargs,
|
| 823 |
-
)
|
| 824 |
-
response: RecraftImageGenerationResponse = operation.execute()
|
| 825 |
-
svg_data = []
|
| 826 |
-
urls = []
|
| 827 |
-
for data in response.data:
|
| 828 |
-
if unique_id and data.url:
|
| 829 |
-
urls.append(data.url)
|
| 830 |
-
# Print result on each iteration in case of error
|
| 831 |
-
PromptServer.instance.send_progress_text(
|
| 832 |
-
f"Result URL: {' '.join(urls)}", unique_id
|
| 833 |
-
)
|
| 834 |
-
svg_data.append(download_url_to_bytesio(data.url, timeout=1024))
|
| 835 |
-
|
| 836 |
-
return (SVG(svg_data),)
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
class RecraftVectorizeImageNode:
|
| 840 |
-
"""
|
| 841 |
-
Generates SVG synchronously from an input image.
|
| 842 |
-
"""
|
| 843 |
-
|
| 844 |
-
RETURN_TYPES = ("SVG",) # Changed
|
| 845 |
-
DESCRIPTION = cleandoc(__doc__ or "") if 'cleandoc' in globals() else __doc__ # Keep cleandoc if other nodes use it
|
| 846 |
-
FUNCTION = "api_call"
|
| 847 |
-
API_NODE = True
|
| 848 |
-
CATEGORY = "api node/image/Recraft"
|
| 849 |
-
|
| 850 |
-
@classmethod
|
| 851 |
-
def INPUT_TYPES(s):
|
| 852 |
-
return {
|
| 853 |
-
"required": {
|
| 854 |
-
"image": (IO.IMAGE, ),
|
| 855 |
-
},
|
| 856 |
-
"optional": {
|
| 857 |
-
},
|
| 858 |
-
"hidden": {
|
| 859 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 860 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 861 |
-
},
|
| 862 |
-
}
|
| 863 |
-
|
| 864 |
-
def api_call(
|
| 865 |
-
self,
|
| 866 |
-
image: torch.Tensor,
|
| 867 |
-
**kwargs,
|
| 868 |
-
):
|
| 869 |
-
svgs = []
|
| 870 |
-
total = image.shape[0]
|
| 871 |
-
pbar = ProgressBar(total)
|
| 872 |
-
for i in range(total):
|
| 873 |
-
sub_bytes = handle_recraft_file_request(
|
| 874 |
-
image=image[i],
|
| 875 |
-
path="/proxy/recraft/images/vectorize",
|
| 876 |
-
auth_kwargs=kwargs,
|
| 877 |
-
)
|
| 878 |
-
svgs.append(SVG(sub_bytes))
|
| 879 |
-
pbar.update(1)
|
| 880 |
-
|
| 881 |
-
return (SVG.combine_all(svgs), )
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
class RecraftReplaceBackgroundNode:
|
| 885 |
-
"""
|
| 886 |
-
Replace background on image, based on provided prompt.
|
| 887 |
-
"""
|
| 888 |
-
|
| 889 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 890 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 891 |
-
FUNCTION = "api_call"
|
| 892 |
-
API_NODE = True
|
| 893 |
-
CATEGORY = "api node/image/Recraft"
|
| 894 |
-
|
| 895 |
-
@classmethod
|
| 896 |
-
def INPUT_TYPES(s):
|
| 897 |
-
return {
|
| 898 |
-
"required": {
|
| 899 |
-
"image": (IO.IMAGE, ),
|
| 900 |
-
"prompt": (
|
| 901 |
-
IO.STRING,
|
| 902 |
-
{
|
| 903 |
-
"multiline": True,
|
| 904 |
-
"default": "",
|
| 905 |
-
"tooltip": "Prompt for the image generation.",
|
| 906 |
-
},
|
| 907 |
-
),
|
| 908 |
-
"n": (
|
| 909 |
-
IO.INT,
|
| 910 |
-
{
|
| 911 |
-
"default": 1,
|
| 912 |
-
"min": 1,
|
| 913 |
-
"max": 6,
|
| 914 |
-
"tooltip": "The number of images to generate.",
|
| 915 |
-
},
|
| 916 |
-
),
|
| 917 |
-
"seed": (
|
| 918 |
-
IO.INT,
|
| 919 |
-
{
|
| 920 |
-
"default": 0,
|
| 921 |
-
"min": 0,
|
| 922 |
-
"max": 0xFFFFFFFFFFFFFFFF,
|
| 923 |
-
"control_after_generate": True,
|
| 924 |
-
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
|
| 925 |
-
},
|
| 926 |
-
),
|
| 927 |
-
},
|
| 928 |
-
"optional": {
|
| 929 |
-
"recraft_style": (RecraftIO.STYLEV3,),
|
| 930 |
-
"negative_prompt": (
|
| 931 |
-
IO.STRING,
|
| 932 |
-
{
|
| 933 |
-
"default": "",
|
| 934 |
-
"forceInput": True,
|
| 935 |
-
"tooltip": "An optional text description of undesired elements on an image.",
|
| 936 |
-
},
|
| 937 |
-
),
|
| 938 |
-
},
|
| 939 |
-
"hidden": {
|
| 940 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 941 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 942 |
-
},
|
| 943 |
-
}
|
| 944 |
-
|
| 945 |
-
def api_call(
|
| 946 |
-
self,
|
| 947 |
-
image: torch.Tensor,
|
| 948 |
-
prompt: str,
|
| 949 |
-
n: int,
|
| 950 |
-
seed,
|
| 951 |
-
recraft_style: RecraftStyle = None,
|
| 952 |
-
negative_prompt: str = None,
|
| 953 |
-
**kwargs,
|
| 954 |
-
):
|
| 955 |
-
default_style = RecraftStyle(RecraftStyleV3.realistic_image)
|
| 956 |
-
if recraft_style is None:
|
| 957 |
-
recraft_style = default_style
|
| 958 |
-
|
| 959 |
-
if not negative_prompt:
|
| 960 |
-
negative_prompt = None
|
| 961 |
-
|
| 962 |
-
request = RecraftImageGenerationRequest(
|
| 963 |
-
prompt=prompt,
|
| 964 |
-
negative_prompt=negative_prompt,
|
| 965 |
-
model=RecraftModel.recraftv3,
|
| 966 |
-
n=n,
|
| 967 |
-
style=recraft_style.style,
|
| 968 |
-
substyle=recraft_style.substyle,
|
| 969 |
-
style_id=recraft_style.style_id,
|
| 970 |
-
)
|
| 971 |
-
|
| 972 |
-
images = []
|
| 973 |
-
total = image.shape[0]
|
| 974 |
-
pbar = ProgressBar(total)
|
| 975 |
-
for i in range(total):
|
| 976 |
-
sub_bytes = handle_recraft_file_request(
|
| 977 |
-
image=image[i],
|
| 978 |
-
path="/proxy/recraft/images/replaceBackground",
|
| 979 |
-
request=request,
|
| 980 |
-
auth_kwargs=kwargs,
|
| 981 |
-
)
|
| 982 |
-
images.append(torch.cat([bytesio_to_image_tensor(x) for x in sub_bytes], dim=0))
|
| 983 |
-
pbar.update(1)
|
| 984 |
-
|
| 985 |
-
images_tensor = torch.cat(images, dim=0)
|
| 986 |
-
return (images_tensor, )
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
class RecraftRemoveBackgroundNode:
|
| 990 |
-
"""
|
| 991 |
-
Remove background from image, and return processed image and mask.
|
| 992 |
-
"""
|
| 993 |
-
|
| 994 |
-
RETURN_TYPES = (IO.IMAGE, IO.MASK)
|
| 995 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 996 |
-
FUNCTION = "api_call"
|
| 997 |
-
API_NODE = True
|
| 998 |
-
CATEGORY = "api node/image/Recraft"
|
| 999 |
-
|
| 1000 |
-
@classmethod
|
| 1001 |
-
def INPUT_TYPES(s):
|
| 1002 |
-
return {
|
| 1003 |
-
"required": {
|
| 1004 |
-
"image": (IO.IMAGE, ),
|
| 1005 |
-
},
|
| 1006 |
-
"optional": {
|
| 1007 |
-
},
|
| 1008 |
-
"hidden": {
|
| 1009 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 1010 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 1011 |
-
},
|
| 1012 |
-
}
|
| 1013 |
-
|
| 1014 |
-
def api_call(
|
| 1015 |
-
self,
|
| 1016 |
-
image: torch.Tensor,
|
| 1017 |
-
**kwargs,
|
| 1018 |
-
):
|
| 1019 |
-
images = []
|
| 1020 |
-
total = image.shape[0]
|
| 1021 |
-
pbar = ProgressBar(total)
|
| 1022 |
-
for i in range(total):
|
| 1023 |
-
sub_bytes = handle_recraft_file_request(
|
| 1024 |
-
image=image[i],
|
| 1025 |
-
path="/proxy/recraft/images/removeBackground",
|
| 1026 |
-
auth_kwargs=kwargs,
|
| 1027 |
-
)
|
| 1028 |
-
images.append(torch.cat([bytesio_to_image_tensor(x) for x in sub_bytes], dim=0))
|
| 1029 |
-
pbar.update(1)
|
| 1030 |
-
|
| 1031 |
-
images_tensor = torch.cat(images, dim=0)
|
| 1032 |
-
# use alpha channel as masks, in B,H,W format
|
| 1033 |
-
masks_tensor = images_tensor[:,:,:,-1:].squeeze(-1)
|
| 1034 |
-
return (images_tensor, masks_tensor)
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
class RecraftCrispUpscaleNode:
|
| 1038 |
-
"""
|
| 1039 |
-
Upscale image synchronously.
|
| 1040 |
-
Enhances a given raster image using ‘crisp upscale’ tool, increasing image resolution, making the image sharper and cleaner.
|
| 1041 |
-
"""
|
| 1042 |
-
|
| 1043 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 1044 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 1045 |
-
FUNCTION = "api_call"
|
| 1046 |
-
API_NODE = True
|
| 1047 |
-
CATEGORY = "api node/image/Recraft"
|
| 1048 |
-
|
| 1049 |
-
RECRAFT_PATH = "/proxy/recraft/images/crispUpscale"
|
| 1050 |
-
|
| 1051 |
-
@classmethod
|
| 1052 |
-
def INPUT_TYPES(s):
|
| 1053 |
-
return {
|
| 1054 |
-
"required": {
|
| 1055 |
-
"image": (IO.IMAGE, ),
|
| 1056 |
-
},
|
| 1057 |
-
"optional": {
|
| 1058 |
-
},
|
| 1059 |
-
"hidden": {
|
| 1060 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 1061 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 1062 |
-
},
|
| 1063 |
-
}
|
| 1064 |
-
|
| 1065 |
-
def api_call(
|
| 1066 |
-
self,
|
| 1067 |
-
image: torch.Tensor,
|
| 1068 |
-
**kwargs,
|
| 1069 |
-
):
|
| 1070 |
-
images = []
|
| 1071 |
-
total = image.shape[0]
|
| 1072 |
-
pbar = ProgressBar(total)
|
| 1073 |
-
for i in range(total):
|
| 1074 |
-
sub_bytes = handle_recraft_file_request(
|
| 1075 |
-
image=image[i],
|
| 1076 |
-
path=self.RECRAFT_PATH,
|
| 1077 |
-
auth_kwargs=kwargs,
|
| 1078 |
-
)
|
| 1079 |
-
images.append(torch.cat([bytesio_to_image_tensor(x) for x in sub_bytes], dim=0))
|
| 1080 |
-
pbar.update(1)
|
| 1081 |
-
|
| 1082 |
-
images_tensor = torch.cat(images, dim=0)
|
| 1083 |
-
return (images_tensor,)
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
class RecraftCreativeUpscaleNode(RecraftCrispUpscaleNode):
|
| 1087 |
-
"""
|
| 1088 |
-
Upscale image synchronously.
|
| 1089 |
-
Enhances a given raster image using ‘creative upscale’ tool, boosting resolution with a focus on refining small details and faces.
|
| 1090 |
-
"""
|
| 1091 |
-
|
| 1092 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 1093 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 1094 |
-
FUNCTION = "api_call"
|
| 1095 |
-
API_NODE = True
|
| 1096 |
-
CATEGORY = "api node/image/Recraft"
|
| 1097 |
-
|
| 1098 |
-
RECRAFT_PATH = "/proxy/recraft/images/creativeUpscale"
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
# A dictionary that contains all nodes you want to export with their names
|
| 1102 |
-
# NOTE: names should be globally unique
|
| 1103 |
-
NODE_CLASS_MAPPINGS = {
|
| 1104 |
-
"RecraftTextToImageNode": RecraftTextToImageNode,
|
| 1105 |
-
"RecraftImageToImageNode": RecraftImageToImageNode,
|
| 1106 |
-
"RecraftImageInpaintingNode": RecraftImageInpaintingNode,
|
| 1107 |
-
"RecraftTextToVectorNode": RecraftTextToVectorNode,
|
| 1108 |
-
"RecraftVectorizeImageNode": RecraftVectorizeImageNode,
|
| 1109 |
-
"RecraftRemoveBackgroundNode": RecraftRemoveBackgroundNode,
|
| 1110 |
-
"RecraftReplaceBackgroundNode": RecraftReplaceBackgroundNode,
|
| 1111 |
-
"RecraftCrispUpscaleNode": RecraftCrispUpscaleNode,
|
| 1112 |
-
"RecraftCreativeUpscaleNode": RecraftCreativeUpscaleNode,
|
| 1113 |
-
"RecraftStyleV3RealisticImage": RecraftStyleV3RealisticImageNode,
|
| 1114 |
-
"RecraftStyleV3DigitalIllustration": RecraftStyleV3DigitalIllustrationNode,
|
| 1115 |
-
"RecraftStyleV3LogoRaster": RecraftStyleV3LogoRasterNode,
|
| 1116 |
-
"RecraftStyleV3InfiniteStyleLibrary": RecraftStyleInfiniteStyleLibrary,
|
| 1117 |
-
"RecraftColorRGB": RecraftColorRGBNode,
|
| 1118 |
-
"RecraftControls": RecraftControlsNode,
|
| 1119 |
-
}
|
| 1120 |
-
|
| 1121 |
-
# A dictionary that contains the friendly/humanly readable titles for the nodes
|
| 1122 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 1123 |
-
"RecraftTextToImageNode": "Recraft Text to Image",
|
| 1124 |
-
"RecraftImageToImageNode": "Recraft Image to Image",
|
| 1125 |
-
"RecraftImageInpaintingNode": "Recraft Image Inpainting",
|
| 1126 |
-
"RecraftTextToVectorNode": "Recraft Text to Vector",
|
| 1127 |
-
"RecraftVectorizeImageNode": "Recraft Vectorize Image",
|
| 1128 |
-
"RecraftRemoveBackgroundNode": "Recraft Remove Background",
|
| 1129 |
-
"RecraftReplaceBackgroundNode": "Recraft Replace Background",
|
| 1130 |
-
"RecraftCrispUpscaleNode": "Recraft Crisp Upscale Image",
|
| 1131 |
-
"RecraftCreativeUpscaleNode": "Recraft Creative Upscale Image",
|
| 1132 |
-
"RecraftStyleV3RealisticImage": "Recraft Style - Realistic Image",
|
| 1133 |
-
"RecraftStyleV3DigitalIllustration": "Recraft Style - Digital Illustration",
|
| 1134 |
-
"RecraftStyleV3LogoRaster": "Recraft Style - Logo Raster",
|
| 1135 |
-
"RecraftStyleV3InfiniteStyleLibrary": "Recraft Style - Infinite Style Library",
|
| 1136 |
-
"RecraftColorRGB": "Recraft Color RGB",
|
| 1137 |
-
"RecraftControls": "Recraft Controls",
|
| 1138 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_rodin.py
DELETED
|
@@ -1,462 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
ComfyUI X Rodin3D(Deemos) API Nodes
|
| 3 |
-
|
| 4 |
-
Rodin API docs: https://developer.hyper3d.ai/
|
| 5 |
-
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
from inspect import cleandoc
|
| 10 |
-
from comfy.comfy_types.node_typing import IO
|
| 11 |
-
import folder_paths as comfy_paths
|
| 12 |
-
import requests
|
| 13 |
-
import os
|
| 14 |
-
import datetime
|
| 15 |
-
import shutil
|
| 16 |
-
import time
|
| 17 |
-
import io
|
| 18 |
-
import logging
|
| 19 |
-
import math
|
| 20 |
-
from PIL import Image
|
| 21 |
-
from comfy_api_nodes.apis.rodin_api import (
|
| 22 |
-
Rodin3DGenerateRequest,
|
| 23 |
-
Rodin3DGenerateResponse,
|
| 24 |
-
Rodin3DCheckStatusRequest,
|
| 25 |
-
Rodin3DCheckStatusResponse,
|
| 26 |
-
Rodin3DDownloadRequest,
|
| 27 |
-
Rodin3DDownloadResponse,
|
| 28 |
-
JobStatus,
|
| 29 |
-
)
|
| 30 |
-
from comfy_api_nodes.apis.client import (
|
| 31 |
-
ApiEndpoint,
|
| 32 |
-
HttpMethod,
|
| 33 |
-
SynchronousOperation,
|
| 34 |
-
PollingOperation,
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
COMMON_PARAMETERS = {
|
| 39 |
-
"Seed": (
|
| 40 |
-
IO.INT,
|
| 41 |
-
{
|
| 42 |
-
"default":0,
|
| 43 |
-
"min":0,
|
| 44 |
-
"max":65535,
|
| 45 |
-
"display":"number"
|
| 46 |
-
}
|
| 47 |
-
),
|
| 48 |
-
"Material_Type": (
|
| 49 |
-
IO.COMBO,
|
| 50 |
-
{
|
| 51 |
-
"options": ["PBR", "Shaded"],
|
| 52 |
-
"default": "PBR"
|
| 53 |
-
}
|
| 54 |
-
),
|
| 55 |
-
"Polygon_count": (
|
| 56 |
-
IO.COMBO,
|
| 57 |
-
{
|
| 58 |
-
"options": ["4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "200K-Triangle"],
|
| 59 |
-
"default": "18K-Quad"
|
| 60 |
-
}
|
| 61 |
-
)
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
def create_task_error(response: Rodin3DGenerateResponse):
|
| 65 |
-
"""Check if the response has error"""
|
| 66 |
-
return hasattr(response, "error")
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
class Rodin3DAPI:
|
| 71 |
-
"""
|
| 72 |
-
Generate 3D Assets using Rodin API
|
| 73 |
-
"""
|
| 74 |
-
RETURN_TYPES = (IO.STRING,)
|
| 75 |
-
RETURN_NAMES = ("3D Model Path",)
|
| 76 |
-
CATEGORY = "api node/3d/Rodin"
|
| 77 |
-
DESCRIPTION = cleandoc(__doc__ or "")
|
| 78 |
-
FUNCTION = "api_call"
|
| 79 |
-
API_NODE = True
|
| 80 |
-
|
| 81 |
-
def tensor_to_filelike(self, tensor, max_pixels: int = 2048*2048):
|
| 82 |
-
"""
|
| 83 |
-
Converts a PyTorch tensor to a file-like object.
|
| 84 |
-
|
| 85 |
-
Args:
|
| 86 |
-
- tensor (torch.Tensor): A tensor representing an image of shape (H, W, C)
|
| 87 |
-
where C is the number of channels (3 for RGB), H is height, and W is width.
|
| 88 |
-
|
| 89 |
-
Returns:
|
| 90 |
-
- io.BytesIO: A file-like object containing the image data.
|
| 91 |
-
"""
|
| 92 |
-
array = tensor.cpu().numpy()
|
| 93 |
-
array = (array * 255).astype('uint8')
|
| 94 |
-
image = Image.fromarray(array, 'RGB')
|
| 95 |
-
|
| 96 |
-
original_width, original_height = image.size
|
| 97 |
-
original_pixels = original_width * original_height
|
| 98 |
-
if original_pixels > max_pixels:
|
| 99 |
-
scale = math.sqrt(max_pixels / original_pixels)
|
| 100 |
-
new_width = int(original_width * scale)
|
| 101 |
-
new_height = int(original_height * scale)
|
| 102 |
-
else:
|
| 103 |
-
new_width, new_height = original_width, original_height
|
| 104 |
-
|
| 105 |
-
if new_width != original_width or new_height != original_height:
|
| 106 |
-
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
| 107 |
-
|
| 108 |
-
img_byte_arr = io.BytesIO()
|
| 109 |
-
image.save(img_byte_arr, format='PNG') # PNG is used for lossless compression
|
| 110 |
-
img_byte_arr.seek(0)
|
| 111 |
-
return img_byte_arr
|
| 112 |
-
|
| 113 |
-
def check_rodin_status(self, response: Rodin3DCheckStatusResponse) -> str:
|
| 114 |
-
has_failed = any(job.status == JobStatus.Failed for job in response.jobs)
|
| 115 |
-
all_done = all(job.status == JobStatus.Done for job in response.jobs)
|
| 116 |
-
status_list = [str(job.status) for job in response.jobs]
|
| 117 |
-
logging.info(f"[ Rodin3D API - CheckStatus ] Generate Status: {status_list}")
|
| 118 |
-
if has_failed:
|
| 119 |
-
logging.error(f"[ Rodin3D API - CheckStatus ] Generate Failed: {status_list}, Please try again.")
|
| 120 |
-
raise Exception("[ Rodin3D API ] Generate Failed, Please Try again.")
|
| 121 |
-
elif all_done:
|
| 122 |
-
return "DONE"
|
| 123 |
-
else:
|
| 124 |
-
return "Generating"
|
| 125 |
-
|
| 126 |
-
def CreateGenerateTask(self, images=None, seed=1, material="PBR", quality="medium", tier="Regular", mesh_mode="Quad", **kwargs):
|
| 127 |
-
if images == None:
|
| 128 |
-
raise Exception("Rodin 3D generate requires at least 1 image.")
|
| 129 |
-
if len(images) >= 5:
|
| 130 |
-
raise Exception("Rodin 3D generate requires up to 5 image.")
|
| 131 |
-
|
| 132 |
-
path = "/proxy/rodin/api/v2/rodin"
|
| 133 |
-
operation = SynchronousOperation(
|
| 134 |
-
endpoint=ApiEndpoint(
|
| 135 |
-
path=path,
|
| 136 |
-
method=HttpMethod.POST,
|
| 137 |
-
request_model=Rodin3DGenerateRequest,
|
| 138 |
-
response_model=Rodin3DGenerateResponse,
|
| 139 |
-
),
|
| 140 |
-
request=Rodin3DGenerateRequest(
|
| 141 |
-
seed=seed,
|
| 142 |
-
tier=tier,
|
| 143 |
-
material=material,
|
| 144 |
-
quality=quality,
|
| 145 |
-
mesh_mode=mesh_mode
|
| 146 |
-
),
|
| 147 |
-
files=[
|
| 148 |
-
(
|
| 149 |
-
"images",
|
| 150 |
-
open(image, "rb") if isinstance(image, str) else self.tensor_to_filelike(image)
|
| 151 |
-
)
|
| 152 |
-
for image in images if image is not None
|
| 153 |
-
],
|
| 154 |
-
content_type = "multipart/form-data",
|
| 155 |
-
auth_kwargs=kwargs,
|
| 156 |
-
)
|
| 157 |
-
|
| 158 |
-
response = operation.execute()
|
| 159 |
-
|
| 160 |
-
if create_task_error(response):
|
| 161 |
-
error_message = f"Rodin3D Create 3D generate Task Failed. Message: {response.message}, error: {response.error}"
|
| 162 |
-
logging.error(error_message)
|
| 163 |
-
raise Exception(error_message)
|
| 164 |
-
|
| 165 |
-
logging.info("[ Rodin3D API - Submit Jobs ] Submit Generate Task Success!")
|
| 166 |
-
subscription_key = response.jobs.subscription_key
|
| 167 |
-
task_uuid = response.uuid
|
| 168 |
-
logging.info(f"[ Rodin3D API - Submit Jobs ] UUID: {task_uuid}")
|
| 169 |
-
return task_uuid, subscription_key
|
| 170 |
-
|
| 171 |
-
def poll_for_task_status(self, subscription_key, **kwargs) -> Rodin3DCheckStatusResponse:
|
| 172 |
-
|
| 173 |
-
path = "/proxy/rodin/api/v2/status"
|
| 174 |
-
|
| 175 |
-
poll_operation = PollingOperation(
|
| 176 |
-
poll_endpoint=ApiEndpoint(
|
| 177 |
-
path = path,
|
| 178 |
-
method=HttpMethod.POST,
|
| 179 |
-
request_model=Rodin3DCheckStatusRequest,
|
| 180 |
-
response_model=Rodin3DCheckStatusResponse,
|
| 181 |
-
),
|
| 182 |
-
request=Rodin3DCheckStatusRequest(
|
| 183 |
-
subscription_key = subscription_key
|
| 184 |
-
),
|
| 185 |
-
completed_statuses=["DONE"],
|
| 186 |
-
failed_statuses=["FAILED"],
|
| 187 |
-
status_extractor=self.check_rodin_status,
|
| 188 |
-
poll_interval=3.0,
|
| 189 |
-
auth_kwargs=kwargs,
|
| 190 |
-
)
|
| 191 |
-
|
| 192 |
-
logging.info("[ Rodin3D API - CheckStatus ] Generate Start!")
|
| 193 |
-
|
| 194 |
-
return poll_operation.execute()
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
def GetRodinDownloadList(self, uuid, **kwargs) -> Rodin3DDownloadResponse:
|
| 199 |
-
logging.info("[ Rodin3D API - Downloading ] Generate Successfully!")
|
| 200 |
-
|
| 201 |
-
path = "/proxy/rodin/api/v2/download"
|
| 202 |
-
operation = SynchronousOperation(
|
| 203 |
-
endpoint=ApiEndpoint(
|
| 204 |
-
path=path,
|
| 205 |
-
method=HttpMethod.POST,
|
| 206 |
-
request_model=Rodin3DDownloadRequest,
|
| 207 |
-
response_model=Rodin3DDownloadResponse,
|
| 208 |
-
),
|
| 209 |
-
request=Rodin3DDownloadRequest(
|
| 210 |
-
task_uuid=uuid
|
| 211 |
-
),
|
| 212 |
-
auth_kwargs=kwargs
|
| 213 |
-
)
|
| 214 |
-
|
| 215 |
-
return operation.execute()
|
| 216 |
-
|
| 217 |
-
def GetQualityAndMode(self, PolyCount):
|
| 218 |
-
if PolyCount == "200K-Triangle":
|
| 219 |
-
mesh_mode = "Raw"
|
| 220 |
-
quality = "medium"
|
| 221 |
-
else:
|
| 222 |
-
mesh_mode = "Quad"
|
| 223 |
-
if PolyCount == "4K-Quad":
|
| 224 |
-
quality = "extra-low"
|
| 225 |
-
elif PolyCount == "8K-Quad":
|
| 226 |
-
quality = "low"
|
| 227 |
-
elif PolyCount == "18K-Quad":
|
| 228 |
-
quality = "medium"
|
| 229 |
-
elif PolyCount == "50K-Quad":
|
| 230 |
-
quality = "high"
|
| 231 |
-
else:
|
| 232 |
-
quality = "medium"
|
| 233 |
-
|
| 234 |
-
return mesh_mode, quality
|
| 235 |
-
|
| 236 |
-
def DownLoadFiles(self, Url_List):
|
| 237 |
-
Save_path = os.path.join(comfy_paths.get_output_directory(), "Rodin3D", datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S"))
|
| 238 |
-
os.makedirs(Save_path, exist_ok=True)
|
| 239 |
-
model_file_path = None
|
| 240 |
-
for Item in Url_List.list:
|
| 241 |
-
url = Item.url
|
| 242 |
-
file_name = Item.name
|
| 243 |
-
file_path = os.path.join(Save_path, file_name)
|
| 244 |
-
if file_path.endswith(".glb"):
|
| 245 |
-
model_file_path = file_path
|
| 246 |
-
logging.info(f"[ Rodin3D API - download_files ] Downloading file: {file_path}")
|
| 247 |
-
max_retries = 5
|
| 248 |
-
for attempt in range(max_retries):
|
| 249 |
-
try:
|
| 250 |
-
with requests.get(url, stream=True) as r:
|
| 251 |
-
r.raise_for_status()
|
| 252 |
-
with open(file_path, "wb") as f:
|
| 253 |
-
shutil.copyfileobj(r.raw, f)
|
| 254 |
-
break
|
| 255 |
-
except Exception as e:
|
| 256 |
-
logging.info(f"[ Rodin3D API - download_files ] Error downloading {file_path}:{e}")
|
| 257 |
-
if attempt < max_retries - 1:
|
| 258 |
-
logging.info("Retrying...")
|
| 259 |
-
time.sleep(2)
|
| 260 |
-
else:
|
| 261 |
-
logging.info(f"[ Rodin3D API - download_files ] Failed to download {file_path} after {max_retries} attempts.")
|
| 262 |
-
|
| 263 |
-
return model_file_path
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
class Rodin3D_Regular(Rodin3DAPI):
|
| 267 |
-
@classmethod
|
| 268 |
-
def INPUT_TYPES(s):
|
| 269 |
-
return {
|
| 270 |
-
"required": {
|
| 271 |
-
"Images":
|
| 272 |
-
(
|
| 273 |
-
IO.IMAGE,
|
| 274 |
-
{
|
| 275 |
-
"forceInput":True,
|
| 276 |
-
}
|
| 277 |
-
)
|
| 278 |
-
},
|
| 279 |
-
"optional": {
|
| 280 |
-
**COMMON_PARAMETERS
|
| 281 |
-
},
|
| 282 |
-
"hidden": {
|
| 283 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 284 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 285 |
-
},
|
| 286 |
-
}
|
| 287 |
-
|
| 288 |
-
def api_call(
|
| 289 |
-
self,
|
| 290 |
-
Images,
|
| 291 |
-
Seed,
|
| 292 |
-
Material_Type,
|
| 293 |
-
Polygon_count,
|
| 294 |
-
**kwargs
|
| 295 |
-
):
|
| 296 |
-
tier = "Regular"
|
| 297 |
-
num_images = Images.shape[0]
|
| 298 |
-
m_images = []
|
| 299 |
-
for i in range(num_images):
|
| 300 |
-
m_images.append(Images[i])
|
| 301 |
-
mesh_mode, quality = self.GetQualityAndMode(Polygon_count)
|
| 302 |
-
task_uuid, subscription_key = self.CreateGenerateTask(images=m_images, seed=Seed, material=Material_Type, quality=quality, tier=tier, mesh_mode=mesh_mode, **kwargs)
|
| 303 |
-
self.poll_for_task_status(subscription_key, **kwargs)
|
| 304 |
-
Download_List = self.GetRodinDownloadList(task_uuid, **kwargs)
|
| 305 |
-
model = self.DownLoadFiles(Download_List)
|
| 306 |
-
|
| 307 |
-
return (model,)
|
| 308 |
-
|
| 309 |
-
class Rodin3D_Detail(Rodin3DAPI):
|
| 310 |
-
@classmethod
|
| 311 |
-
def INPUT_TYPES(s):
|
| 312 |
-
return {
|
| 313 |
-
"required": {
|
| 314 |
-
"Images":
|
| 315 |
-
(
|
| 316 |
-
IO.IMAGE,
|
| 317 |
-
{
|
| 318 |
-
"forceInput":True,
|
| 319 |
-
}
|
| 320 |
-
)
|
| 321 |
-
},
|
| 322 |
-
"optional": {
|
| 323 |
-
**COMMON_PARAMETERS
|
| 324 |
-
},
|
| 325 |
-
"hidden": {
|
| 326 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 327 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 328 |
-
},
|
| 329 |
-
}
|
| 330 |
-
|
| 331 |
-
def api_call(
|
| 332 |
-
self,
|
| 333 |
-
Images,
|
| 334 |
-
Seed,
|
| 335 |
-
Material_Type,
|
| 336 |
-
Polygon_count,
|
| 337 |
-
**kwargs
|
| 338 |
-
):
|
| 339 |
-
tier = "Detail"
|
| 340 |
-
num_images = Images.shape[0]
|
| 341 |
-
m_images = []
|
| 342 |
-
for i in range(num_images):
|
| 343 |
-
m_images.append(Images[i])
|
| 344 |
-
mesh_mode, quality = self.GetQualityAndMode(Polygon_count)
|
| 345 |
-
task_uuid, subscription_key = self.CreateGenerateTask(images=m_images, seed=Seed, material=Material_Type, quality=quality, tier=tier, mesh_mode=mesh_mode, **kwargs)
|
| 346 |
-
self.poll_for_task_status(subscription_key, **kwargs)
|
| 347 |
-
Download_List = self.GetRodinDownloadList(task_uuid, **kwargs)
|
| 348 |
-
model = self.DownLoadFiles(Download_List)
|
| 349 |
-
|
| 350 |
-
return (model,)
|
| 351 |
-
|
| 352 |
-
class Rodin3D_Smooth(Rodin3DAPI):
|
| 353 |
-
@classmethod
|
| 354 |
-
def INPUT_TYPES(s):
|
| 355 |
-
return {
|
| 356 |
-
"required": {
|
| 357 |
-
"Images":
|
| 358 |
-
(
|
| 359 |
-
IO.IMAGE,
|
| 360 |
-
{
|
| 361 |
-
"forceInput":True,
|
| 362 |
-
}
|
| 363 |
-
)
|
| 364 |
-
},
|
| 365 |
-
"optional": {
|
| 366 |
-
**COMMON_PARAMETERS
|
| 367 |
-
},
|
| 368 |
-
"hidden": {
|
| 369 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 370 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 371 |
-
},
|
| 372 |
-
}
|
| 373 |
-
|
| 374 |
-
def api_call(
|
| 375 |
-
self,
|
| 376 |
-
Images,
|
| 377 |
-
Seed,
|
| 378 |
-
Material_Type,
|
| 379 |
-
Polygon_count,
|
| 380 |
-
**kwargs
|
| 381 |
-
):
|
| 382 |
-
tier = "Smooth"
|
| 383 |
-
num_images = Images.shape[0]
|
| 384 |
-
m_images = []
|
| 385 |
-
for i in range(num_images):
|
| 386 |
-
m_images.append(Images[i])
|
| 387 |
-
mesh_mode, quality = self.GetQualityAndMode(Polygon_count)
|
| 388 |
-
task_uuid, subscription_key = self.CreateGenerateTask(images=m_images, seed=Seed, material=Material_Type, quality=quality, tier=tier, mesh_mode=mesh_mode, **kwargs)
|
| 389 |
-
self.poll_for_task_status(subscription_key, **kwargs)
|
| 390 |
-
Download_List = self.GetRodinDownloadList(task_uuid, **kwargs)
|
| 391 |
-
model = self.DownLoadFiles(Download_List)
|
| 392 |
-
|
| 393 |
-
return (model,)
|
| 394 |
-
|
| 395 |
-
class Rodin3D_Sketch(Rodin3DAPI):
|
| 396 |
-
@classmethod
|
| 397 |
-
def INPUT_TYPES(s):
|
| 398 |
-
return {
|
| 399 |
-
"required": {
|
| 400 |
-
"Images":
|
| 401 |
-
(
|
| 402 |
-
IO.IMAGE,
|
| 403 |
-
{
|
| 404 |
-
"forceInput":True,
|
| 405 |
-
}
|
| 406 |
-
)
|
| 407 |
-
},
|
| 408 |
-
"optional": {
|
| 409 |
-
"Seed":
|
| 410 |
-
(
|
| 411 |
-
IO.INT,
|
| 412 |
-
{
|
| 413 |
-
"default":0,
|
| 414 |
-
"min":0,
|
| 415 |
-
"max":65535,
|
| 416 |
-
"display":"number"
|
| 417 |
-
}
|
| 418 |
-
)
|
| 419 |
-
},
|
| 420 |
-
"hidden": {
|
| 421 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 422 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 423 |
-
},
|
| 424 |
-
}
|
| 425 |
-
|
| 426 |
-
def api_call(
|
| 427 |
-
self,
|
| 428 |
-
Images,
|
| 429 |
-
Seed,
|
| 430 |
-
**kwargs
|
| 431 |
-
):
|
| 432 |
-
tier = "Sketch"
|
| 433 |
-
num_images = Images.shape[0]
|
| 434 |
-
m_images = []
|
| 435 |
-
for i in range(num_images):
|
| 436 |
-
m_images.append(Images[i])
|
| 437 |
-
material_type = "PBR"
|
| 438 |
-
quality = "medium"
|
| 439 |
-
mesh_mode = "Quad"
|
| 440 |
-
task_uuid, subscription_key = self.CreateGenerateTask(images=m_images, seed=Seed, material=material_type, quality=quality, tier=tier, mesh_mode=mesh_mode, **kwargs)
|
| 441 |
-
self.poll_for_task_status(subscription_key, **kwargs)
|
| 442 |
-
Download_List = self.GetRodinDownloadList(task_uuid, **kwargs)
|
| 443 |
-
model = self.DownLoadFiles(Download_List)
|
| 444 |
-
|
| 445 |
-
return (model,)
|
| 446 |
-
|
| 447 |
-
# A dictionary that contains all nodes you want to export with their names
|
| 448 |
-
# NOTE: names should be globally unique
|
| 449 |
-
NODE_CLASS_MAPPINGS = {
|
| 450 |
-
"Rodin3D_Regular": Rodin3D_Regular,
|
| 451 |
-
"Rodin3D_Detail": Rodin3D_Detail,
|
| 452 |
-
"Rodin3D_Smooth": Rodin3D_Smooth,
|
| 453 |
-
"Rodin3D_Sketch": Rodin3D_Sketch,
|
| 454 |
-
}
|
| 455 |
-
|
| 456 |
-
# A dictionary that contains the friendly/humanly readable titles for the nodes
|
| 457 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 458 |
-
"Rodin3D_Regular": "Rodin 3D Generate - Regular Generate",
|
| 459 |
-
"Rodin3D_Detail": "Rodin 3D Generate - Detail Generate",
|
| 460 |
-
"Rodin3D_Smooth": "Rodin 3D Generate - Smooth Generate",
|
| 461 |
-
"Rodin3D_Sketch": "Rodin 3D Generate - Sketch Generate",
|
| 462 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_runway.py
DELETED
|
@@ -1,635 +0,0 @@
|
|
| 1 |
-
"""Runway API Nodes
|
| 2 |
-
|
| 3 |
-
API Docs:
|
| 4 |
-
- https://docs.dev.runwayml.com/api/#tag/Task-management/paths/~1v1~1tasks~1%7Bid%7D/delete
|
| 5 |
-
|
| 6 |
-
User Guides:
|
| 7 |
-
- https://help.runwayml.com/hc/en-us/sections/30265301423635-Gen-3-Alpha
|
| 8 |
-
- https://help.runwayml.com/hc/en-us/articles/37327109429011-Creating-with-Gen-4-Video
|
| 9 |
-
- https://help.runwayml.com/hc/en-us/articles/33927968552339-Creating-with-Act-One-on-Gen-3-Alpha-and-Turbo
|
| 10 |
-
- https://help.runwayml.com/hc/en-us/articles/34170748696595-Creating-with-Keyframes-on-Gen-3
|
| 11 |
-
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from typing import Union, Optional, Any
|
| 15 |
-
from enum import Enum
|
| 16 |
-
|
| 17 |
-
import torch
|
| 18 |
-
|
| 19 |
-
from comfy_api_nodes.apis import (
|
| 20 |
-
RunwayImageToVideoRequest,
|
| 21 |
-
RunwayImageToVideoResponse,
|
| 22 |
-
RunwayTaskStatusResponse as TaskStatusResponse,
|
| 23 |
-
RunwayTaskStatusEnum as TaskStatus,
|
| 24 |
-
RunwayModelEnum as Model,
|
| 25 |
-
RunwayDurationEnum as Duration,
|
| 26 |
-
RunwayAspectRatioEnum as AspectRatio,
|
| 27 |
-
RunwayPromptImageObject,
|
| 28 |
-
RunwayPromptImageDetailedObject,
|
| 29 |
-
RunwayTextToImageRequest,
|
| 30 |
-
RunwayTextToImageResponse,
|
| 31 |
-
Model4,
|
| 32 |
-
ReferenceImage,
|
| 33 |
-
RunwayTextToImageAspectRatioEnum,
|
| 34 |
-
)
|
| 35 |
-
from comfy_api_nodes.apis.client import (
|
| 36 |
-
ApiEndpoint,
|
| 37 |
-
HttpMethod,
|
| 38 |
-
SynchronousOperation,
|
| 39 |
-
PollingOperation,
|
| 40 |
-
EmptyRequest,
|
| 41 |
-
)
|
| 42 |
-
from comfy_api_nodes.apinode_utils import (
|
| 43 |
-
upload_images_to_comfyapi,
|
| 44 |
-
download_url_to_video_output,
|
| 45 |
-
image_tensor_pair_to_batch,
|
| 46 |
-
validate_string,
|
| 47 |
-
download_url_to_image_tensor,
|
| 48 |
-
)
|
| 49 |
-
from comfy_api_nodes.mapper_utils import model_field_to_node_input
|
| 50 |
-
from comfy_api.input_impl import VideoFromFile
|
| 51 |
-
from comfy.comfy_types.node_typing import IO, ComfyNodeABC
|
| 52 |
-
|
| 53 |
-
PATH_IMAGE_TO_VIDEO = "/proxy/runway/image_to_video"
|
| 54 |
-
PATH_TEXT_TO_IMAGE = "/proxy/runway/text_to_image"
|
| 55 |
-
PATH_GET_TASK_STATUS = "/proxy/runway/tasks"
|
| 56 |
-
|
| 57 |
-
AVERAGE_DURATION_I2V_SECONDS = 64
|
| 58 |
-
AVERAGE_DURATION_FLF_SECONDS = 256
|
| 59 |
-
AVERAGE_DURATION_T2I_SECONDS = 41
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
class RunwayApiError(Exception):
|
| 63 |
-
"""Base exception for Runway API errors."""
|
| 64 |
-
|
| 65 |
-
pass
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
class RunwayGen4TurboAspectRatio(str, Enum):
|
| 69 |
-
"""Aspect ratios supported for Image to Video API when using gen4_turbo model."""
|
| 70 |
-
|
| 71 |
-
field_1280_720 = "1280:720"
|
| 72 |
-
field_720_1280 = "720:1280"
|
| 73 |
-
field_1104_832 = "1104:832"
|
| 74 |
-
field_832_1104 = "832:1104"
|
| 75 |
-
field_960_960 = "960:960"
|
| 76 |
-
field_1584_672 = "1584:672"
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
class RunwayGen3aAspectRatio(str, Enum):
|
| 80 |
-
"""Aspect ratios supported for Image to Video API when using gen3a_turbo model."""
|
| 81 |
-
|
| 82 |
-
field_768_1280 = "768:1280"
|
| 83 |
-
field_1280_768 = "1280:768"
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def get_video_url_from_task_status(response: TaskStatusResponse) -> Union[str, None]:
|
| 87 |
-
"""Returns the video URL from the task status response if it exists."""
|
| 88 |
-
if response.output and len(response.output) > 0:
|
| 89 |
-
return response.output[0]
|
| 90 |
-
return None
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
# TODO: replace with updated image validation utils (upstream)
|
| 94 |
-
def validate_input_image(image: torch.Tensor) -> bool:
|
| 95 |
-
"""
|
| 96 |
-
Validate the input image is within the size limits for the Runway API.
|
| 97 |
-
See: https://docs.dev.runwayml.com/assets/inputs/#common-error-reasons
|
| 98 |
-
"""
|
| 99 |
-
return image.shape[2] < 8000 and image.shape[1] < 8000
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def poll_until_finished(
|
| 103 |
-
auth_kwargs: dict[str, str],
|
| 104 |
-
api_endpoint: ApiEndpoint[Any, TaskStatusResponse],
|
| 105 |
-
estimated_duration: Optional[int] = None,
|
| 106 |
-
node_id: Optional[str] = None,
|
| 107 |
-
) -> TaskStatusResponse:
|
| 108 |
-
"""Polls the Runway API endpoint until the task reaches a terminal state, then returns the response."""
|
| 109 |
-
return PollingOperation(
|
| 110 |
-
poll_endpoint=api_endpoint,
|
| 111 |
-
completed_statuses=[
|
| 112 |
-
TaskStatus.SUCCEEDED.value,
|
| 113 |
-
],
|
| 114 |
-
failed_statuses=[
|
| 115 |
-
TaskStatus.FAILED.value,
|
| 116 |
-
TaskStatus.CANCELLED.value,
|
| 117 |
-
],
|
| 118 |
-
status_extractor=lambda response: (response.status.value),
|
| 119 |
-
auth_kwargs=auth_kwargs,
|
| 120 |
-
result_url_extractor=get_video_url_from_task_status,
|
| 121 |
-
estimated_duration=estimated_duration,
|
| 122 |
-
node_id=node_id,
|
| 123 |
-
progress_extractor=extract_progress_from_task_status,
|
| 124 |
-
).execute()
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def extract_progress_from_task_status(
|
| 128 |
-
response: TaskStatusResponse,
|
| 129 |
-
) -> Union[float, None]:
|
| 130 |
-
if hasattr(response, "progress") and response.progress is not None:
|
| 131 |
-
return response.progress * 100
|
| 132 |
-
return None
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def get_image_url_from_task_status(response: TaskStatusResponse) -> Union[str, None]:
|
| 136 |
-
"""Returns the image URL from the task status response if it exists."""
|
| 137 |
-
if response.output and len(response.output) > 0:
|
| 138 |
-
return response.output[0]
|
| 139 |
-
return None
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
class RunwayVideoGenNode(ComfyNodeABC):
|
| 143 |
-
"""Runway Video Node Base."""
|
| 144 |
-
|
| 145 |
-
RETURN_TYPES = ("VIDEO",)
|
| 146 |
-
FUNCTION = "api_call"
|
| 147 |
-
CATEGORY = "api node/video/Runway"
|
| 148 |
-
API_NODE = True
|
| 149 |
-
|
| 150 |
-
def validate_task_created(self, response: RunwayImageToVideoResponse) -> bool:
|
| 151 |
-
"""
|
| 152 |
-
Validate the task creation response from the Runway API matches
|
| 153 |
-
expected format.
|
| 154 |
-
"""
|
| 155 |
-
if not bool(response.id):
|
| 156 |
-
raise RunwayApiError("Invalid initial response from Runway API.")
|
| 157 |
-
return True
|
| 158 |
-
|
| 159 |
-
def validate_response(self, response: RunwayImageToVideoResponse) -> bool:
|
| 160 |
-
"""
|
| 161 |
-
Validate the successful task status response from the Runway API
|
| 162 |
-
matches expected format.
|
| 163 |
-
"""
|
| 164 |
-
if not response.output or len(response.output) == 0:
|
| 165 |
-
raise RunwayApiError(
|
| 166 |
-
"Runway task succeeded but no video data found in response."
|
| 167 |
-
)
|
| 168 |
-
return True
|
| 169 |
-
|
| 170 |
-
def get_response(
|
| 171 |
-
self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
|
| 172 |
-
) -> RunwayImageToVideoResponse:
|
| 173 |
-
"""Poll the task status until it is finished then get the response."""
|
| 174 |
-
return poll_until_finished(
|
| 175 |
-
auth_kwargs,
|
| 176 |
-
ApiEndpoint(
|
| 177 |
-
path=f"{PATH_GET_TASK_STATUS}/{task_id}",
|
| 178 |
-
method=HttpMethod.GET,
|
| 179 |
-
request_model=EmptyRequest,
|
| 180 |
-
response_model=TaskStatusResponse,
|
| 181 |
-
),
|
| 182 |
-
estimated_duration=AVERAGE_DURATION_FLF_SECONDS,
|
| 183 |
-
node_id=node_id,
|
| 184 |
-
)
|
| 185 |
-
|
| 186 |
-
def generate_video(
|
| 187 |
-
self,
|
| 188 |
-
request: RunwayImageToVideoRequest,
|
| 189 |
-
auth_kwargs: dict[str, str],
|
| 190 |
-
node_id: Optional[str] = None,
|
| 191 |
-
) -> tuple[VideoFromFile]:
|
| 192 |
-
initial_operation = SynchronousOperation(
|
| 193 |
-
endpoint=ApiEndpoint(
|
| 194 |
-
path=PATH_IMAGE_TO_VIDEO,
|
| 195 |
-
method=HttpMethod.POST,
|
| 196 |
-
request_model=RunwayImageToVideoRequest,
|
| 197 |
-
response_model=RunwayImageToVideoResponse,
|
| 198 |
-
),
|
| 199 |
-
request=request,
|
| 200 |
-
auth_kwargs=auth_kwargs,
|
| 201 |
-
)
|
| 202 |
-
|
| 203 |
-
initial_response = initial_operation.execute()
|
| 204 |
-
self.validate_task_created(initial_response)
|
| 205 |
-
task_id = initial_response.id
|
| 206 |
-
|
| 207 |
-
final_response = self.get_response(task_id, auth_kwargs, node_id)
|
| 208 |
-
self.validate_response(final_response)
|
| 209 |
-
|
| 210 |
-
video_url = get_video_url_from_task_status(final_response)
|
| 211 |
-
return (download_url_to_video_output(video_url),)
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
class RunwayImageToVideoNodeGen3a(RunwayVideoGenNode):
|
| 215 |
-
"""Runway Image to Video Node using Gen3a Turbo model."""
|
| 216 |
-
|
| 217 |
-
DESCRIPTION = "Generate a video from a single starting frame using Gen3a Turbo model. Before diving in, review these best practices to ensure that your input selections will set your generation up for success: https://help.runwayml.com/hc/en-us/articles/33927968552339-Creating-with-Act-One-on-Gen-3-Alpha-and-Turbo."
|
| 218 |
-
|
| 219 |
-
@classmethod
|
| 220 |
-
def INPUT_TYPES(s):
|
| 221 |
-
return {
|
| 222 |
-
"required": {
|
| 223 |
-
"prompt": model_field_to_node_input(
|
| 224 |
-
IO.STRING, RunwayImageToVideoRequest, "promptText", multiline=True
|
| 225 |
-
),
|
| 226 |
-
"start_frame": (
|
| 227 |
-
IO.IMAGE,
|
| 228 |
-
{"tooltip": "Start frame to be used for the video"},
|
| 229 |
-
),
|
| 230 |
-
"duration": model_field_to_node_input(
|
| 231 |
-
IO.COMBO, RunwayImageToVideoRequest, "duration", enum_type=Duration
|
| 232 |
-
),
|
| 233 |
-
"ratio": model_field_to_node_input(
|
| 234 |
-
IO.COMBO,
|
| 235 |
-
RunwayImageToVideoRequest,
|
| 236 |
-
"ratio",
|
| 237 |
-
enum_type=RunwayGen3aAspectRatio,
|
| 238 |
-
),
|
| 239 |
-
"seed": model_field_to_node_input(
|
| 240 |
-
IO.INT,
|
| 241 |
-
RunwayImageToVideoRequest,
|
| 242 |
-
"seed",
|
| 243 |
-
control_after_generate=True,
|
| 244 |
-
),
|
| 245 |
-
},
|
| 246 |
-
"hidden": {
|
| 247 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 248 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 249 |
-
"unique_id": "UNIQUE_ID",
|
| 250 |
-
},
|
| 251 |
-
}
|
| 252 |
-
|
| 253 |
-
def api_call(
|
| 254 |
-
self,
|
| 255 |
-
prompt: str,
|
| 256 |
-
start_frame: torch.Tensor,
|
| 257 |
-
duration: str,
|
| 258 |
-
ratio: str,
|
| 259 |
-
seed: int,
|
| 260 |
-
unique_id: Optional[str] = None,
|
| 261 |
-
**kwargs,
|
| 262 |
-
) -> tuple[VideoFromFile]:
|
| 263 |
-
# Validate inputs
|
| 264 |
-
validate_string(prompt, min_length=1)
|
| 265 |
-
validate_input_image(start_frame)
|
| 266 |
-
|
| 267 |
-
# Upload image
|
| 268 |
-
download_urls = upload_images_to_comfyapi(
|
| 269 |
-
start_frame,
|
| 270 |
-
max_images=1,
|
| 271 |
-
mime_type="image/png",
|
| 272 |
-
auth_kwargs=kwargs,
|
| 273 |
-
)
|
| 274 |
-
if len(download_urls) != 1:
|
| 275 |
-
raise RunwayApiError("Failed to upload one or more images to comfy api.")
|
| 276 |
-
|
| 277 |
-
return self.generate_video(
|
| 278 |
-
RunwayImageToVideoRequest(
|
| 279 |
-
promptText=prompt,
|
| 280 |
-
seed=seed,
|
| 281 |
-
model=Model("gen3a_turbo"),
|
| 282 |
-
duration=Duration(duration),
|
| 283 |
-
ratio=AspectRatio(ratio),
|
| 284 |
-
promptImage=RunwayPromptImageObject(
|
| 285 |
-
root=[
|
| 286 |
-
RunwayPromptImageDetailedObject(
|
| 287 |
-
uri=str(download_urls[0]), position="first"
|
| 288 |
-
)
|
| 289 |
-
]
|
| 290 |
-
),
|
| 291 |
-
),
|
| 292 |
-
auth_kwargs=kwargs,
|
| 293 |
-
node_id=unique_id,
|
| 294 |
-
)
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
class RunwayImageToVideoNodeGen4(RunwayVideoGenNode):
|
| 298 |
-
"""Runway Image to Video Node using Gen4 Turbo model."""
|
| 299 |
-
|
| 300 |
-
DESCRIPTION = "Generate a video from a single starting frame using Gen4 Turbo model. Before diving in, review these best practices to ensure that your input selections will set your generation up for success: https://help.runwayml.com/hc/en-us/articles/37327109429011-Creating-with-Gen-4-Video."
|
| 301 |
-
|
| 302 |
-
@classmethod
|
| 303 |
-
def INPUT_TYPES(s):
|
| 304 |
-
return {
|
| 305 |
-
"required": {
|
| 306 |
-
"prompt": model_field_to_node_input(
|
| 307 |
-
IO.STRING, RunwayImageToVideoRequest, "promptText", multiline=True
|
| 308 |
-
),
|
| 309 |
-
"start_frame": (
|
| 310 |
-
IO.IMAGE,
|
| 311 |
-
{"tooltip": "Start frame to be used for the video"},
|
| 312 |
-
),
|
| 313 |
-
"duration": model_field_to_node_input(
|
| 314 |
-
IO.COMBO, RunwayImageToVideoRequest, "duration", enum_type=Duration
|
| 315 |
-
),
|
| 316 |
-
"ratio": model_field_to_node_input(
|
| 317 |
-
IO.COMBO,
|
| 318 |
-
RunwayImageToVideoRequest,
|
| 319 |
-
"ratio",
|
| 320 |
-
enum_type=RunwayGen4TurboAspectRatio,
|
| 321 |
-
),
|
| 322 |
-
"seed": model_field_to_node_input(
|
| 323 |
-
IO.INT,
|
| 324 |
-
RunwayImageToVideoRequest,
|
| 325 |
-
"seed",
|
| 326 |
-
control_after_generate=True,
|
| 327 |
-
),
|
| 328 |
-
},
|
| 329 |
-
"hidden": {
|
| 330 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 331 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 332 |
-
"unique_id": "UNIQUE_ID",
|
| 333 |
-
},
|
| 334 |
-
}
|
| 335 |
-
|
| 336 |
-
def api_call(
|
| 337 |
-
self,
|
| 338 |
-
prompt: str,
|
| 339 |
-
start_frame: torch.Tensor,
|
| 340 |
-
duration: str,
|
| 341 |
-
ratio: str,
|
| 342 |
-
seed: int,
|
| 343 |
-
unique_id: Optional[str] = None,
|
| 344 |
-
**kwargs,
|
| 345 |
-
) -> tuple[VideoFromFile]:
|
| 346 |
-
# Validate inputs
|
| 347 |
-
validate_string(prompt, min_length=1)
|
| 348 |
-
validate_input_image(start_frame)
|
| 349 |
-
|
| 350 |
-
# Upload image
|
| 351 |
-
download_urls = upload_images_to_comfyapi(
|
| 352 |
-
start_frame,
|
| 353 |
-
max_images=1,
|
| 354 |
-
mime_type="image/png",
|
| 355 |
-
auth_kwargs=kwargs,
|
| 356 |
-
)
|
| 357 |
-
if len(download_urls) != 1:
|
| 358 |
-
raise RunwayApiError("Failed to upload one or more images to comfy api.")
|
| 359 |
-
|
| 360 |
-
return self.generate_video(
|
| 361 |
-
RunwayImageToVideoRequest(
|
| 362 |
-
promptText=prompt,
|
| 363 |
-
seed=seed,
|
| 364 |
-
model=Model("gen4_turbo"),
|
| 365 |
-
duration=Duration(duration),
|
| 366 |
-
ratio=AspectRatio(ratio),
|
| 367 |
-
promptImage=RunwayPromptImageObject(
|
| 368 |
-
root=[
|
| 369 |
-
RunwayPromptImageDetailedObject(
|
| 370 |
-
uri=str(download_urls[0]), position="first"
|
| 371 |
-
)
|
| 372 |
-
]
|
| 373 |
-
),
|
| 374 |
-
),
|
| 375 |
-
auth_kwargs=kwargs,
|
| 376 |
-
node_id=unique_id,
|
| 377 |
-
)
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
class RunwayFirstLastFrameNode(RunwayVideoGenNode):
|
| 381 |
-
"""Runway First-Last Frame Node."""
|
| 382 |
-
|
| 383 |
-
DESCRIPTION = "Upload first and last keyframes, draft a prompt, and generate a video. More complex transitions, such as cases where the Last frame is completely different from the First frame, may benefit from the longer 10s duration. This would give the generation more time to smoothly transition between the two inputs. Before diving in, review these best practices to ensure that your input selections will set your generation up for success: https://help.runwayml.com/hc/en-us/articles/34170748696595-Creating-with-Keyframes-on-Gen-3."
|
| 384 |
-
|
| 385 |
-
def get_response(
|
| 386 |
-
self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
|
| 387 |
-
) -> RunwayImageToVideoResponse:
|
| 388 |
-
return poll_until_finished(
|
| 389 |
-
auth_kwargs,
|
| 390 |
-
ApiEndpoint(
|
| 391 |
-
path=f"{PATH_GET_TASK_STATUS}/{task_id}",
|
| 392 |
-
method=HttpMethod.GET,
|
| 393 |
-
request_model=EmptyRequest,
|
| 394 |
-
response_model=TaskStatusResponse,
|
| 395 |
-
),
|
| 396 |
-
estimated_duration=AVERAGE_DURATION_FLF_SECONDS,
|
| 397 |
-
node_id=node_id,
|
| 398 |
-
)
|
| 399 |
-
|
| 400 |
-
@classmethod
|
| 401 |
-
def INPUT_TYPES(s):
|
| 402 |
-
return {
|
| 403 |
-
"required": {
|
| 404 |
-
"prompt": model_field_to_node_input(
|
| 405 |
-
IO.STRING, RunwayImageToVideoRequest, "promptText", multiline=True
|
| 406 |
-
),
|
| 407 |
-
"start_frame": (
|
| 408 |
-
IO.IMAGE,
|
| 409 |
-
{"tooltip": "Start frame to be used for the video"},
|
| 410 |
-
),
|
| 411 |
-
"end_frame": (
|
| 412 |
-
IO.IMAGE,
|
| 413 |
-
{
|
| 414 |
-
"tooltip": "End frame to be used for the video. Supported for gen3a_turbo only."
|
| 415 |
-
},
|
| 416 |
-
),
|
| 417 |
-
"duration": model_field_to_node_input(
|
| 418 |
-
IO.COMBO, RunwayImageToVideoRequest, "duration", enum_type=Duration
|
| 419 |
-
),
|
| 420 |
-
"ratio": model_field_to_node_input(
|
| 421 |
-
IO.COMBO,
|
| 422 |
-
RunwayImageToVideoRequest,
|
| 423 |
-
"ratio",
|
| 424 |
-
enum_type=RunwayGen3aAspectRatio,
|
| 425 |
-
),
|
| 426 |
-
"seed": model_field_to_node_input(
|
| 427 |
-
IO.INT,
|
| 428 |
-
RunwayImageToVideoRequest,
|
| 429 |
-
"seed",
|
| 430 |
-
control_after_generate=True,
|
| 431 |
-
),
|
| 432 |
-
},
|
| 433 |
-
"hidden": {
|
| 434 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 435 |
-
"unique_id": "UNIQUE_ID",
|
| 436 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 437 |
-
},
|
| 438 |
-
}
|
| 439 |
-
|
| 440 |
-
def api_call(
|
| 441 |
-
self,
|
| 442 |
-
prompt: str,
|
| 443 |
-
start_frame: torch.Tensor,
|
| 444 |
-
end_frame: torch.Tensor,
|
| 445 |
-
duration: str,
|
| 446 |
-
ratio: str,
|
| 447 |
-
seed: int,
|
| 448 |
-
unique_id: Optional[str] = None,
|
| 449 |
-
**kwargs,
|
| 450 |
-
) -> tuple[VideoFromFile]:
|
| 451 |
-
# Validate inputs
|
| 452 |
-
validate_string(prompt, min_length=1)
|
| 453 |
-
validate_input_image(start_frame)
|
| 454 |
-
validate_input_image(end_frame)
|
| 455 |
-
|
| 456 |
-
# Upload images
|
| 457 |
-
stacked_input_images = image_tensor_pair_to_batch(start_frame, end_frame)
|
| 458 |
-
download_urls = upload_images_to_comfyapi(
|
| 459 |
-
stacked_input_images,
|
| 460 |
-
max_images=2,
|
| 461 |
-
mime_type="image/png",
|
| 462 |
-
auth_kwargs=kwargs,
|
| 463 |
-
)
|
| 464 |
-
if len(download_urls) != 2:
|
| 465 |
-
raise RunwayApiError("Failed to upload one or more images to comfy api.")
|
| 466 |
-
|
| 467 |
-
return self.generate_video(
|
| 468 |
-
RunwayImageToVideoRequest(
|
| 469 |
-
promptText=prompt,
|
| 470 |
-
seed=seed,
|
| 471 |
-
model=Model("gen3a_turbo"),
|
| 472 |
-
duration=Duration(duration),
|
| 473 |
-
ratio=AspectRatio(ratio),
|
| 474 |
-
promptImage=RunwayPromptImageObject(
|
| 475 |
-
root=[
|
| 476 |
-
RunwayPromptImageDetailedObject(
|
| 477 |
-
uri=str(download_urls[0]), position="first"
|
| 478 |
-
),
|
| 479 |
-
RunwayPromptImageDetailedObject(
|
| 480 |
-
uri=str(download_urls[1]), position="last"
|
| 481 |
-
),
|
| 482 |
-
]
|
| 483 |
-
),
|
| 484 |
-
),
|
| 485 |
-
auth_kwargs=kwargs,
|
| 486 |
-
node_id=unique_id,
|
| 487 |
-
)
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
class RunwayTextToImageNode(ComfyNodeABC):
|
| 491 |
-
"""Runway Text to Image Node."""
|
| 492 |
-
|
| 493 |
-
RETURN_TYPES = ("IMAGE",)
|
| 494 |
-
FUNCTION = "api_call"
|
| 495 |
-
CATEGORY = "api node/image/Runway"
|
| 496 |
-
API_NODE = True
|
| 497 |
-
DESCRIPTION = "Generate an image from a text prompt using Runway's Gen 4 model. You can also include reference images to guide the generation."
|
| 498 |
-
|
| 499 |
-
@classmethod
|
| 500 |
-
def INPUT_TYPES(s):
|
| 501 |
-
return {
|
| 502 |
-
"required": {
|
| 503 |
-
"prompt": model_field_to_node_input(
|
| 504 |
-
IO.STRING, RunwayTextToImageRequest, "promptText", multiline=True
|
| 505 |
-
),
|
| 506 |
-
"ratio": model_field_to_node_input(
|
| 507 |
-
IO.COMBO,
|
| 508 |
-
RunwayTextToImageRequest,
|
| 509 |
-
"ratio",
|
| 510 |
-
enum_type=RunwayTextToImageAspectRatioEnum,
|
| 511 |
-
),
|
| 512 |
-
},
|
| 513 |
-
"optional": {
|
| 514 |
-
"reference_image": (
|
| 515 |
-
IO.IMAGE,
|
| 516 |
-
{"tooltip": "Optional reference image to guide the generation"},
|
| 517 |
-
)
|
| 518 |
-
},
|
| 519 |
-
"hidden": {
|
| 520 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 521 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 522 |
-
"unique_id": "UNIQUE_ID",
|
| 523 |
-
},
|
| 524 |
-
}
|
| 525 |
-
|
| 526 |
-
def validate_task_created(self, response: RunwayTextToImageResponse) -> bool:
|
| 527 |
-
"""
|
| 528 |
-
Validate the task creation response from the Runway API matches
|
| 529 |
-
expected format.
|
| 530 |
-
"""
|
| 531 |
-
if not bool(response.id):
|
| 532 |
-
raise RunwayApiError("Invalid initial response from Runway API.")
|
| 533 |
-
return True
|
| 534 |
-
|
| 535 |
-
def validate_response(self, response: TaskStatusResponse) -> bool:
|
| 536 |
-
"""
|
| 537 |
-
Validate the successful task status response from the Runway API
|
| 538 |
-
matches expected format.
|
| 539 |
-
"""
|
| 540 |
-
if not response.output or len(response.output) == 0:
|
| 541 |
-
raise RunwayApiError(
|
| 542 |
-
"Runway task succeeded but no image data found in response."
|
| 543 |
-
)
|
| 544 |
-
return True
|
| 545 |
-
|
| 546 |
-
def get_response(
|
| 547 |
-
self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
|
| 548 |
-
) -> TaskStatusResponse:
|
| 549 |
-
"""Poll the task status until it is finished then get the response."""
|
| 550 |
-
return poll_until_finished(
|
| 551 |
-
auth_kwargs,
|
| 552 |
-
ApiEndpoint(
|
| 553 |
-
path=f"{PATH_GET_TASK_STATUS}/{task_id}",
|
| 554 |
-
method=HttpMethod.GET,
|
| 555 |
-
request_model=EmptyRequest,
|
| 556 |
-
response_model=TaskStatusResponse,
|
| 557 |
-
),
|
| 558 |
-
estimated_duration=AVERAGE_DURATION_T2I_SECONDS,
|
| 559 |
-
node_id=node_id,
|
| 560 |
-
)
|
| 561 |
-
|
| 562 |
-
def api_call(
|
| 563 |
-
self,
|
| 564 |
-
prompt: str,
|
| 565 |
-
ratio: str,
|
| 566 |
-
reference_image: Optional[torch.Tensor] = None,
|
| 567 |
-
unique_id: Optional[str] = None,
|
| 568 |
-
**kwargs,
|
| 569 |
-
) -> tuple[torch.Tensor]:
|
| 570 |
-
# Validate inputs
|
| 571 |
-
validate_string(prompt, min_length=1)
|
| 572 |
-
|
| 573 |
-
# Prepare reference images if provided
|
| 574 |
-
reference_images = None
|
| 575 |
-
if reference_image is not None:
|
| 576 |
-
validate_input_image(reference_image)
|
| 577 |
-
download_urls = upload_images_to_comfyapi(
|
| 578 |
-
reference_image,
|
| 579 |
-
max_images=1,
|
| 580 |
-
mime_type="image/png",
|
| 581 |
-
auth_kwargs=kwargs,
|
| 582 |
-
)
|
| 583 |
-
if len(download_urls) != 1:
|
| 584 |
-
raise RunwayApiError("Failed to upload reference image to comfy api.")
|
| 585 |
-
|
| 586 |
-
reference_images = [ReferenceImage(uri=str(download_urls[0]))]
|
| 587 |
-
|
| 588 |
-
# Create request
|
| 589 |
-
request = RunwayTextToImageRequest(
|
| 590 |
-
promptText=prompt,
|
| 591 |
-
model=Model4.gen4_image,
|
| 592 |
-
ratio=ratio,
|
| 593 |
-
referenceImages=reference_images,
|
| 594 |
-
)
|
| 595 |
-
|
| 596 |
-
# Execute initial request
|
| 597 |
-
initial_operation = SynchronousOperation(
|
| 598 |
-
endpoint=ApiEndpoint(
|
| 599 |
-
path=PATH_TEXT_TO_IMAGE,
|
| 600 |
-
method=HttpMethod.POST,
|
| 601 |
-
request_model=RunwayTextToImageRequest,
|
| 602 |
-
response_model=RunwayTextToImageResponse,
|
| 603 |
-
),
|
| 604 |
-
request=request,
|
| 605 |
-
auth_kwargs=kwargs,
|
| 606 |
-
)
|
| 607 |
-
|
| 608 |
-
initial_response = initial_operation.execute()
|
| 609 |
-
self.validate_task_created(initial_response)
|
| 610 |
-
task_id = initial_response.id
|
| 611 |
-
|
| 612 |
-
# Poll for completion
|
| 613 |
-
final_response = self.get_response(
|
| 614 |
-
task_id, auth_kwargs=kwargs, node_id=unique_id
|
| 615 |
-
)
|
| 616 |
-
self.validate_response(final_response)
|
| 617 |
-
|
| 618 |
-
# Download and return image
|
| 619 |
-
image_url = get_image_url_from_task_status(final_response)
|
| 620 |
-
return (download_url_to_image_tensor(image_url),)
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
NODE_CLASS_MAPPINGS = {
|
| 624 |
-
"RunwayFirstLastFrameNode": RunwayFirstLastFrameNode,
|
| 625 |
-
"RunwayImageToVideoNodeGen3a": RunwayImageToVideoNodeGen3a,
|
| 626 |
-
"RunwayImageToVideoNodeGen4": RunwayImageToVideoNodeGen4,
|
| 627 |
-
"RunwayTextToImageNode": RunwayTextToImageNode,
|
| 628 |
-
}
|
| 629 |
-
|
| 630 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 631 |
-
"RunwayFirstLastFrameNode": "Runway First-Last-Frame to Video",
|
| 632 |
-
"RunwayImageToVideoNodeGen3a": "Runway Image to Video (Gen3a Turbo)",
|
| 633 |
-
"RunwayImageToVideoNodeGen4": "Runway Image to Video (Gen4 Turbo)",
|
| 634 |
-
"RunwayTextToImageNode": "Runway Text to Image",
|
| 635 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_stability.py
DELETED
|
@@ -1,614 +0,0 @@
|
|
| 1 |
-
from inspect import cleandoc
|
| 2 |
-
from comfy.comfy_types.node_typing import IO
|
| 3 |
-
from comfy_api_nodes.apis.stability_api import (
|
| 4 |
-
StabilityUpscaleConservativeRequest,
|
| 5 |
-
StabilityUpscaleCreativeRequest,
|
| 6 |
-
StabilityAsyncResponse,
|
| 7 |
-
StabilityResultsGetResponse,
|
| 8 |
-
StabilityStable3_5Request,
|
| 9 |
-
StabilityStableUltraRequest,
|
| 10 |
-
StabilityStableUltraResponse,
|
| 11 |
-
StabilityAspectRatio,
|
| 12 |
-
Stability_SD3_5_Model,
|
| 13 |
-
Stability_SD3_5_GenerationMode,
|
| 14 |
-
get_stability_style_presets,
|
| 15 |
-
)
|
| 16 |
-
from comfy_api_nodes.apis.client import (
|
| 17 |
-
ApiEndpoint,
|
| 18 |
-
HttpMethod,
|
| 19 |
-
SynchronousOperation,
|
| 20 |
-
PollingOperation,
|
| 21 |
-
EmptyRequest,
|
| 22 |
-
)
|
| 23 |
-
from comfy_api_nodes.apinode_utils import (
|
| 24 |
-
bytesio_to_image_tensor,
|
| 25 |
-
tensor_to_bytesio,
|
| 26 |
-
validate_string,
|
| 27 |
-
)
|
| 28 |
-
|
| 29 |
-
import torch
|
| 30 |
-
import base64
|
| 31 |
-
from io import BytesIO
|
| 32 |
-
from enum import Enum
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
class StabilityPollStatus(str, Enum):
|
| 36 |
-
finished = "finished"
|
| 37 |
-
in_progress = "in_progress"
|
| 38 |
-
failed = "failed"
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def get_async_dummy_status(x: StabilityResultsGetResponse):
|
| 42 |
-
if x.name is not None or x.errors is not None:
|
| 43 |
-
return StabilityPollStatus.failed
|
| 44 |
-
elif x.finish_reason is not None:
|
| 45 |
-
return StabilityPollStatus.finished
|
| 46 |
-
return StabilityPollStatus.in_progress
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
class StabilityStableImageUltraNode:
|
| 50 |
-
"""
|
| 51 |
-
Generates images synchronously based on prompt and resolution.
|
| 52 |
-
"""
|
| 53 |
-
|
| 54 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 55 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 56 |
-
FUNCTION = "api_call"
|
| 57 |
-
API_NODE = True
|
| 58 |
-
CATEGORY = "api node/image/Stability AI"
|
| 59 |
-
|
| 60 |
-
@classmethod
|
| 61 |
-
def INPUT_TYPES(s):
|
| 62 |
-
return {
|
| 63 |
-
"required": {
|
| 64 |
-
"prompt": (
|
| 65 |
-
IO.STRING,
|
| 66 |
-
{
|
| 67 |
-
"multiline": True,
|
| 68 |
-
"default": "",
|
| 69 |
-
"tooltip": "What you wish to see in the output image. A strong, descriptive prompt that clearly defines" +
|
| 70 |
-
"What you wish to see in the output image. A strong, descriptive prompt that clearly defines" +
|
| 71 |
-
"elements, colors, and subjects will lead to better results. " +
|
| 72 |
-
"To control the weight of a given word use the format `(word:weight)`," +
|
| 73 |
-
"where `word` is the word you'd like to control the weight of and `weight`" +
|
| 74 |
-
"is a value between 0 and 1. For example: `The sky was a crisp (blue:0.3) and (green:0.8)`" +
|
| 75 |
-
"would convey a sky that was blue and green, but more green than blue."
|
| 76 |
-
},
|
| 77 |
-
),
|
| 78 |
-
"aspect_ratio": ([x.value for x in StabilityAspectRatio],
|
| 79 |
-
{
|
| 80 |
-
"default": StabilityAspectRatio.ratio_1_1,
|
| 81 |
-
"tooltip": "Aspect ratio of generated image.",
|
| 82 |
-
},
|
| 83 |
-
),
|
| 84 |
-
"style_preset": (get_stability_style_presets(),
|
| 85 |
-
{
|
| 86 |
-
"tooltip": "Optional desired style of generated image.",
|
| 87 |
-
},
|
| 88 |
-
),
|
| 89 |
-
"seed": (
|
| 90 |
-
IO.INT,
|
| 91 |
-
{
|
| 92 |
-
"default": 0,
|
| 93 |
-
"min": 0,
|
| 94 |
-
"max": 4294967294,
|
| 95 |
-
"control_after_generate": True,
|
| 96 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 97 |
-
},
|
| 98 |
-
),
|
| 99 |
-
},
|
| 100 |
-
"optional": {
|
| 101 |
-
"image": (IO.IMAGE,),
|
| 102 |
-
"negative_prompt": (
|
| 103 |
-
IO.STRING,
|
| 104 |
-
{
|
| 105 |
-
"default": "",
|
| 106 |
-
"forceInput": True,
|
| 107 |
-
"tooltip": "A blurb of text describing what you do not wish to see in the output image. This is an advanced feature."
|
| 108 |
-
},
|
| 109 |
-
),
|
| 110 |
-
"image_denoise": (
|
| 111 |
-
IO.FLOAT,
|
| 112 |
-
{
|
| 113 |
-
"default": 0.5,
|
| 114 |
-
"min": 0.0,
|
| 115 |
-
"max": 1.0,
|
| 116 |
-
"step": 0.01,
|
| 117 |
-
"tooltip": "Denoise of input image; 0.0 yields image identical to input, 1.0 is as if no image was provided at all.",
|
| 118 |
-
},
|
| 119 |
-
),
|
| 120 |
-
},
|
| 121 |
-
"hidden": {
|
| 122 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 123 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 124 |
-
},
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
def api_call(self, prompt: str, aspect_ratio: str, style_preset: str, seed: int,
|
| 128 |
-
negative_prompt: str=None, image: torch.Tensor = None, image_denoise: float=None,
|
| 129 |
-
**kwargs):
|
| 130 |
-
validate_string(prompt, strip_whitespace=False)
|
| 131 |
-
# prepare image binary if image present
|
| 132 |
-
image_binary = None
|
| 133 |
-
if image is not None:
|
| 134 |
-
image_binary = tensor_to_bytesio(image, total_pixels=1504*1504).read()
|
| 135 |
-
else:
|
| 136 |
-
image_denoise = None
|
| 137 |
-
|
| 138 |
-
if not negative_prompt:
|
| 139 |
-
negative_prompt = None
|
| 140 |
-
if style_preset == "None":
|
| 141 |
-
style_preset = None
|
| 142 |
-
|
| 143 |
-
files = {
|
| 144 |
-
"image": image_binary
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
operation = SynchronousOperation(
|
| 148 |
-
endpoint=ApiEndpoint(
|
| 149 |
-
path="/proxy/stability/v2beta/stable-image/generate/ultra",
|
| 150 |
-
method=HttpMethod.POST,
|
| 151 |
-
request_model=StabilityStableUltraRequest,
|
| 152 |
-
response_model=StabilityStableUltraResponse,
|
| 153 |
-
),
|
| 154 |
-
request=StabilityStableUltraRequest(
|
| 155 |
-
prompt=prompt,
|
| 156 |
-
negative_prompt=negative_prompt,
|
| 157 |
-
aspect_ratio=aspect_ratio,
|
| 158 |
-
seed=seed,
|
| 159 |
-
strength=image_denoise,
|
| 160 |
-
style_preset=style_preset,
|
| 161 |
-
),
|
| 162 |
-
files=files,
|
| 163 |
-
content_type="multipart/form-data",
|
| 164 |
-
auth_kwargs=kwargs,
|
| 165 |
-
)
|
| 166 |
-
response_api = operation.execute()
|
| 167 |
-
|
| 168 |
-
if response_api.finish_reason != "SUCCESS":
|
| 169 |
-
raise Exception(f"Stable Image Ultra generation failed: {response_api.finish_reason}.")
|
| 170 |
-
|
| 171 |
-
image_data = base64.b64decode(response_api.image)
|
| 172 |
-
returned_image = bytesio_to_image_tensor(BytesIO(image_data))
|
| 173 |
-
|
| 174 |
-
return (returned_image,)
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
class StabilityStableImageSD_3_5Node:
|
| 178 |
-
"""
|
| 179 |
-
Generates images synchronously based on prompt and resolution.
|
| 180 |
-
"""
|
| 181 |
-
|
| 182 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 183 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 184 |
-
FUNCTION = "api_call"
|
| 185 |
-
API_NODE = True
|
| 186 |
-
CATEGORY = "api node/image/Stability AI"
|
| 187 |
-
|
| 188 |
-
@classmethod
|
| 189 |
-
def INPUT_TYPES(s):
|
| 190 |
-
return {
|
| 191 |
-
"required": {
|
| 192 |
-
"prompt": (
|
| 193 |
-
IO.STRING,
|
| 194 |
-
{
|
| 195 |
-
"multiline": True,
|
| 196 |
-
"default": "",
|
| 197 |
-
"tooltip": "What you wish to see in the output image. A strong, descriptive prompt that clearly defines elements, colors, and subjects will lead to better results."
|
| 198 |
-
},
|
| 199 |
-
),
|
| 200 |
-
"model": ([x.value for x in Stability_SD3_5_Model],),
|
| 201 |
-
"aspect_ratio": ([x.value for x in StabilityAspectRatio],
|
| 202 |
-
{
|
| 203 |
-
"default": StabilityAspectRatio.ratio_1_1,
|
| 204 |
-
"tooltip": "Aspect ratio of generated image.",
|
| 205 |
-
},
|
| 206 |
-
),
|
| 207 |
-
"style_preset": (get_stability_style_presets(),
|
| 208 |
-
{
|
| 209 |
-
"tooltip": "Optional desired style of generated image.",
|
| 210 |
-
},
|
| 211 |
-
),
|
| 212 |
-
"cfg_scale": (
|
| 213 |
-
IO.FLOAT,
|
| 214 |
-
{
|
| 215 |
-
"default": 4.0,
|
| 216 |
-
"min": 1.0,
|
| 217 |
-
"max": 10.0,
|
| 218 |
-
"step": 0.1,
|
| 219 |
-
"tooltip": "How strictly the diffusion process adheres to the prompt text (higher values keep your image closer to your prompt)",
|
| 220 |
-
},
|
| 221 |
-
),
|
| 222 |
-
"seed": (
|
| 223 |
-
IO.INT,
|
| 224 |
-
{
|
| 225 |
-
"default": 0,
|
| 226 |
-
"min": 0,
|
| 227 |
-
"max": 4294967294,
|
| 228 |
-
"control_after_generate": True,
|
| 229 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 230 |
-
},
|
| 231 |
-
),
|
| 232 |
-
},
|
| 233 |
-
"optional": {
|
| 234 |
-
"image": (IO.IMAGE,),
|
| 235 |
-
"negative_prompt": (
|
| 236 |
-
IO.STRING,
|
| 237 |
-
{
|
| 238 |
-
"default": "",
|
| 239 |
-
"forceInput": True,
|
| 240 |
-
"tooltip": "Keywords of what you do not wish to see in the output image. This is an advanced feature."
|
| 241 |
-
},
|
| 242 |
-
),
|
| 243 |
-
"image_denoise": (
|
| 244 |
-
IO.FLOAT,
|
| 245 |
-
{
|
| 246 |
-
"default": 0.5,
|
| 247 |
-
"min": 0.0,
|
| 248 |
-
"max": 1.0,
|
| 249 |
-
"step": 0.01,
|
| 250 |
-
"tooltip": "Denoise of input image; 0.0 yields image identical to input, 1.0 is as if no image was provided at all.",
|
| 251 |
-
},
|
| 252 |
-
),
|
| 253 |
-
},
|
| 254 |
-
"hidden": {
|
| 255 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 256 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 257 |
-
},
|
| 258 |
-
}
|
| 259 |
-
|
| 260 |
-
def api_call(self, model: str, prompt: str, aspect_ratio: str, style_preset: str, seed: int, cfg_scale: float,
|
| 261 |
-
negative_prompt: str=None, image: torch.Tensor = None, image_denoise: float=None,
|
| 262 |
-
**kwargs):
|
| 263 |
-
validate_string(prompt, strip_whitespace=False)
|
| 264 |
-
# prepare image binary if image present
|
| 265 |
-
image_binary = None
|
| 266 |
-
mode = Stability_SD3_5_GenerationMode.text_to_image
|
| 267 |
-
if image is not None:
|
| 268 |
-
image_binary = tensor_to_bytesio(image, total_pixels=1504*1504).read()
|
| 269 |
-
mode = Stability_SD3_5_GenerationMode.image_to_image
|
| 270 |
-
aspect_ratio = None
|
| 271 |
-
else:
|
| 272 |
-
image_denoise = None
|
| 273 |
-
|
| 274 |
-
if not negative_prompt:
|
| 275 |
-
negative_prompt = None
|
| 276 |
-
if style_preset == "None":
|
| 277 |
-
style_preset = None
|
| 278 |
-
|
| 279 |
-
files = {
|
| 280 |
-
"image": image_binary
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
-
operation = SynchronousOperation(
|
| 284 |
-
endpoint=ApiEndpoint(
|
| 285 |
-
path="/proxy/stability/v2beta/stable-image/generate/sd3",
|
| 286 |
-
method=HttpMethod.POST,
|
| 287 |
-
request_model=StabilityStable3_5Request,
|
| 288 |
-
response_model=StabilityStableUltraResponse,
|
| 289 |
-
),
|
| 290 |
-
request=StabilityStable3_5Request(
|
| 291 |
-
prompt=prompt,
|
| 292 |
-
negative_prompt=negative_prompt,
|
| 293 |
-
aspect_ratio=aspect_ratio,
|
| 294 |
-
seed=seed,
|
| 295 |
-
strength=image_denoise,
|
| 296 |
-
style_preset=style_preset,
|
| 297 |
-
cfg_scale=cfg_scale,
|
| 298 |
-
model=model,
|
| 299 |
-
mode=mode,
|
| 300 |
-
),
|
| 301 |
-
files=files,
|
| 302 |
-
content_type="multipart/form-data",
|
| 303 |
-
auth_kwargs=kwargs,
|
| 304 |
-
)
|
| 305 |
-
response_api = operation.execute()
|
| 306 |
-
|
| 307 |
-
if response_api.finish_reason != "SUCCESS":
|
| 308 |
-
raise Exception(f"Stable Diffusion 3.5 Image generation failed: {response_api.finish_reason}.")
|
| 309 |
-
|
| 310 |
-
image_data = base64.b64decode(response_api.image)
|
| 311 |
-
returned_image = bytesio_to_image_tensor(BytesIO(image_data))
|
| 312 |
-
|
| 313 |
-
return (returned_image,)
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
class StabilityUpscaleConservativeNode:
|
| 317 |
-
"""
|
| 318 |
-
Upscale image with minimal alterations to 4K resolution.
|
| 319 |
-
"""
|
| 320 |
-
|
| 321 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 322 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 323 |
-
FUNCTION = "api_call"
|
| 324 |
-
API_NODE = True
|
| 325 |
-
CATEGORY = "api node/image/Stability AI"
|
| 326 |
-
|
| 327 |
-
@classmethod
|
| 328 |
-
def INPUT_TYPES(s):
|
| 329 |
-
return {
|
| 330 |
-
"required": {
|
| 331 |
-
"image": (IO.IMAGE,),
|
| 332 |
-
"prompt": (
|
| 333 |
-
IO.STRING,
|
| 334 |
-
{
|
| 335 |
-
"multiline": True,
|
| 336 |
-
"default": "",
|
| 337 |
-
"tooltip": "What you wish to see in the output image. A strong, descriptive prompt that clearly defines elements, colors, and subjects will lead to better results."
|
| 338 |
-
},
|
| 339 |
-
),
|
| 340 |
-
"creativity": (
|
| 341 |
-
IO.FLOAT,
|
| 342 |
-
{
|
| 343 |
-
"default": 0.35,
|
| 344 |
-
"min": 0.2,
|
| 345 |
-
"max": 0.5,
|
| 346 |
-
"step": 0.01,
|
| 347 |
-
"tooltip": "Controls the likelihood of creating additional details not heavily conditioned by the init image.",
|
| 348 |
-
},
|
| 349 |
-
),
|
| 350 |
-
"seed": (
|
| 351 |
-
IO.INT,
|
| 352 |
-
{
|
| 353 |
-
"default": 0,
|
| 354 |
-
"min": 0,
|
| 355 |
-
"max": 4294967294,
|
| 356 |
-
"control_after_generate": True,
|
| 357 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 358 |
-
},
|
| 359 |
-
),
|
| 360 |
-
},
|
| 361 |
-
"optional": {
|
| 362 |
-
"negative_prompt": (
|
| 363 |
-
IO.STRING,
|
| 364 |
-
{
|
| 365 |
-
"default": "",
|
| 366 |
-
"forceInput": True,
|
| 367 |
-
"tooltip": "Keywords of what you do not wish to see in the output image. This is an advanced feature."
|
| 368 |
-
},
|
| 369 |
-
),
|
| 370 |
-
},
|
| 371 |
-
"hidden": {
|
| 372 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 373 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 374 |
-
},
|
| 375 |
-
}
|
| 376 |
-
|
| 377 |
-
def api_call(self, image: torch.Tensor, prompt: str, creativity: float, seed: int, negative_prompt: str=None,
|
| 378 |
-
**kwargs):
|
| 379 |
-
validate_string(prompt, strip_whitespace=False)
|
| 380 |
-
image_binary = tensor_to_bytesio(image, total_pixels=1024*1024).read()
|
| 381 |
-
|
| 382 |
-
if not negative_prompt:
|
| 383 |
-
negative_prompt = None
|
| 384 |
-
|
| 385 |
-
files = {
|
| 386 |
-
"image": image_binary
|
| 387 |
-
}
|
| 388 |
-
|
| 389 |
-
operation = SynchronousOperation(
|
| 390 |
-
endpoint=ApiEndpoint(
|
| 391 |
-
path="/proxy/stability/v2beta/stable-image/upscale/conservative",
|
| 392 |
-
method=HttpMethod.POST,
|
| 393 |
-
request_model=StabilityUpscaleConservativeRequest,
|
| 394 |
-
response_model=StabilityStableUltraResponse,
|
| 395 |
-
),
|
| 396 |
-
request=StabilityUpscaleConservativeRequest(
|
| 397 |
-
prompt=prompt,
|
| 398 |
-
negative_prompt=negative_prompt,
|
| 399 |
-
creativity=round(creativity,2),
|
| 400 |
-
seed=seed,
|
| 401 |
-
),
|
| 402 |
-
files=files,
|
| 403 |
-
content_type="multipart/form-data",
|
| 404 |
-
auth_kwargs=kwargs,
|
| 405 |
-
)
|
| 406 |
-
response_api = operation.execute()
|
| 407 |
-
|
| 408 |
-
if response_api.finish_reason != "SUCCESS":
|
| 409 |
-
raise Exception(f"Stability Upscale Conservative generation failed: {response_api.finish_reason}.")
|
| 410 |
-
|
| 411 |
-
image_data = base64.b64decode(response_api.image)
|
| 412 |
-
returned_image = bytesio_to_image_tensor(BytesIO(image_data))
|
| 413 |
-
|
| 414 |
-
return (returned_image,)
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
class StabilityUpscaleCreativeNode:
|
| 418 |
-
"""
|
| 419 |
-
Upscale image with minimal alterations to 4K resolution.
|
| 420 |
-
"""
|
| 421 |
-
|
| 422 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 423 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 424 |
-
FUNCTION = "api_call"
|
| 425 |
-
API_NODE = True
|
| 426 |
-
CATEGORY = "api node/image/Stability AI"
|
| 427 |
-
|
| 428 |
-
@classmethod
|
| 429 |
-
def INPUT_TYPES(s):
|
| 430 |
-
return {
|
| 431 |
-
"required": {
|
| 432 |
-
"image": (IO.IMAGE,),
|
| 433 |
-
"prompt": (
|
| 434 |
-
IO.STRING,
|
| 435 |
-
{
|
| 436 |
-
"multiline": True,
|
| 437 |
-
"default": "",
|
| 438 |
-
"tooltip": "What you wish to see in the output image. A strong, descriptive prompt that clearly defines elements, colors, and subjects will lead to better results."
|
| 439 |
-
},
|
| 440 |
-
),
|
| 441 |
-
"creativity": (
|
| 442 |
-
IO.FLOAT,
|
| 443 |
-
{
|
| 444 |
-
"default": 0.3,
|
| 445 |
-
"min": 0.1,
|
| 446 |
-
"max": 0.5,
|
| 447 |
-
"step": 0.01,
|
| 448 |
-
"tooltip": "Controls the likelihood of creating additional details not heavily conditioned by the init image.",
|
| 449 |
-
},
|
| 450 |
-
),
|
| 451 |
-
"style_preset": (get_stability_style_presets(),
|
| 452 |
-
{
|
| 453 |
-
"tooltip": "Optional desired style of generated image.",
|
| 454 |
-
},
|
| 455 |
-
),
|
| 456 |
-
"seed": (
|
| 457 |
-
IO.INT,
|
| 458 |
-
{
|
| 459 |
-
"default": 0,
|
| 460 |
-
"min": 0,
|
| 461 |
-
"max": 4294967294,
|
| 462 |
-
"control_after_generate": True,
|
| 463 |
-
"tooltip": "The random seed used for creating the noise.",
|
| 464 |
-
},
|
| 465 |
-
),
|
| 466 |
-
},
|
| 467 |
-
"optional": {
|
| 468 |
-
"negative_prompt": (
|
| 469 |
-
IO.STRING,
|
| 470 |
-
{
|
| 471 |
-
"default": "",
|
| 472 |
-
"forceInput": True,
|
| 473 |
-
"tooltip": "Keywords of what you do not wish to see in the output image. This is an advanced feature."
|
| 474 |
-
},
|
| 475 |
-
),
|
| 476 |
-
},
|
| 477 |
-
"hidden": {
|
| 478 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 479 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 480 |
-
},
|
| 481 |
-
}
|
| 482 |
-
|
| 483 |
-
def api_call(self, image: torch.Tensor, prompt: str, creativity: float, style_preset: str, seed: int, negative_prompt: str=None,
|
| 484 |
-
**kwargs):
|
| 485 |
-
validate_string(prompt, strip_whitespace=False)
|
| 486 |
-
image_binary = tensor_to_bytesio(image, total_pixels=1024*1024).read()
|
| 487 |
-
|
| 488 |
-
if not negative_prompt:
|
| 489 |
-
negative_prompt = None
|
| 490 |
-
if style_preset == "None":
|
| 491 |
-
style_preset = None
|
| 492 |
-
|
| 493 |
-
files = {
|
| 494 |
-
"image": image_binary
|
| 495 |
-
}
|
| 496 |
-
|
| 497 |
-
operation = SynchronousOperation(
|
| 498 |
-
endpoint=ApiEndpoint(
|
| 499 |
-
path="/proxy/stability/v2beta/stable-image/upscale/creative",
|
| 500 |
-
method=HttpMethod.POST,
|
| 501 |
-
request_model=StabilityUpscaleCreativeRequest,
|
| 502 |
-
response_model=StabilityAsyncResponse,
|
| 503 |
-
),
|
| 504 |
-
request=StabilityUpscaleCreativeRequest(
|
| 505 |
-
prompt=prompt,
|
| 506 |
-
negative_prompt=negative_prompt,
|
| 507 |
-
creativity=round(creativity,2),
|
| 508 |
-
style_preset=style_preset,
|
| 509 |
-
seed=seed,
|
| 510 |
-
),
|
| 511 |
-
files=files,
|
| 512 |
-
content_type="multipart/form-data",
|
| 513 |
-
auth_kwargs=kwargs,
|
| 514 |
-
)
|
| 515 |
-
response_api = operation.execute()
|
| 516 |
-
|
| 517 |
-
operation = PollingOperation(
|
| 518 |
-
poll_endpoint=ApiEndpoint(
|
| 519 |
-
path=f"/proxy/stability/v2beta/results/{response_api.id}",
|
| 520 |
-
method=HttpMethod.GET,
|
| 521 |
-
request_model=EmptyRequest,
|
| 522 |
-
response_model=StabilityResultsGetResponse,
|
| 523 |
-
),
|
| 524 |
-
poll_interval=3,
|
| 525 |
-
completed_statuses=[StabilityPollStatus.finished],
|
| 526 |
-
failed_statuses=[StabilityPollStatus.failed],
|
| 527 |
-
status_extractor=lambda x: get_async_dummy_status(x),
|
| 528 |
-
auth_kwargs=kwargs,
|
| 529 |
-
)
|
| 530 |
-
response_poll: StabilityResultsGetResponse = operation.execute()
|
| 531 |
-
|
| 532 |
-
if response_poll.finish_reason != "SUCCESS":
|
| 533 |
-
raise Exception(f"Stability Upscale Creative generation failed: {response_poll.finish_reason}.")
|
| 534 |
-
|
| 535 |
-
image_data = base64.b64decode(response_poll.result)
|
| 536 |
-
returned_image = bytesio_to_image_tensor(BytesIO(image_data))
|
| 537 |
-
|
| 538 |
-
return (returned_image,)
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
class StabilityUpscaleFastNode:
|
| 542 |
-
"""
|
| 543 |
-
Quickly upscales an image via Stability API call to 4x its original size; intended for upscaling low-quality/compressed images.
|
| 544 |
-
"""
|
| 545 |
-
|
| 546 |
-
RETURN_TYPES = (IO.IMAGE,)
|
| 547 |
-
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
|
| 548 |
-
FUNCTION = "api_call"
|
| 549 |
-
API_NODE = True
|
| 550 |
-
CATEGORY = "api node/image/Stability AI"
|
| 551 |
-
|
| 552 |
-
@classmethod
|
| 553 |
-
def INPUT_TYPES(s):
|
| 554 |
-
return {
|
| 555 |
-
"required": {
|
| 556 |
-
"image": (IO.IMAGE,),
|
| 557 |
-
},
|
| 558 |
-
"optional": {
|
| 559 |
-
},
|
| 560 |
-
"hidden": {
|
| 561 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 562 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 563 |
-
},
|
| 564 |
-
}
|
| 565 |
-
|
| 566 |
-
def api_call(self, image: torch.Tensor,
|
| 567 |
-
**kwargs):
|
| 568 |
-
image_binary = tensor_to_bytesio(image, total_pixels=4096*4096).read()
|
| 569 |
-
|
| 570 |
-
files = {
|
| 571 |
-
"image": image_binary
|
| 572 |
-
}
|
| 573 |
-
|
| 574 |
-
operation = SynchronousOperation(
|
| 575 |
-
endpoint=ApiEndpoint(
|
| 576 |
-
path="/proxy/stability/v2beta/stable-image/upscale/fast",
|
| 577 |
-
method=HttpMethod.POST,
|
| 578 |
-
request_model=EmptyRequest,
|
| 579 |
-
response_model=StabilityStableUltraResponse,
|
| 580 |
-
),
|
| 581 |
-
request=EmptyRequest(),
|
| 582 |
-
files=files,
|
| 583 |
-
content_type="multipart/form-data",
|
| 584 |
-
auth_kwargs=kwargs,
|
| 585 |
-
)
|
| 586 |
-
response_api = operation.execute()
|
| 587 |
-
|
| 588 |
-
if response_api.finish_reason != "SUCCESS":
|
| 589 |
-
raise Exception(f"Stability Upscale Fast failed: {response_api.finish_reason}.")
|
| 590 |
-
|
| 591 |
-
image_data = base64.b64decode(response_api.image)
|
| 592 |
-
returned_image = bytesio_to_image_tensor(BytesIO(image_data))
|
| 593 |
-
|
| 594 |
-
return (returned_image,)
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
# A dictionary that contains all nodes you want to export with their names
|
| 598 |
-
# NOTE: names should be globally unique
|
| 599 |
-
NODE_CLASS_MAPPINGS = {
|
| 600 |
-
"StabilityStableImageUltraNode": StabilityStableImageUltraNode,
|
| 601 |
-
"StabilityStableImageSD_3_5Node": StabilityStableImageSD_3_5Node,
|
| 602 |
-
"StabilityUpscaleConservativeNode": StabilityUpscaleConservativeNode,
|
| 603 |
-
"StabilityUpscaleCreativeNode": StabilityUpscaleCreativeNode,
|
| 604 |
-
"StabilityUpscaleFastNode": StabilityUpscaleFastNode,
|
| 605 |
-
}
|
| 606 |
-
|
| 607 |
-
# A dictionary that contains the friendly/humanly readable titles for the nodes
|
| 608 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 609 |
-
"StabilityStableImageUltraNode": "Stability AI Stable Image Ultra",
|
| 610 |
-
"StabilityStableImageSD_3_5Node": "Stability AI Stable Diffusion 3.5 Image",
|
| 611 |
-
"StabilityUpscaleConservativeNode": "Stability AI Upscale Conservative",
|
| 612 |
-
"StabilityUpscaleCreativeNode": "Stability AI Upscale Creative",
|
| 613 |
-
"StabilityUpscaleFastNode": "Stability AI Upscale Fast",
|
| 614 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_tripo.py
DELETED
|
@@ -1,574 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
from folder_paths import get_output_directory
|
| 3 |
-
from comfy_api_nodes.mapper_utils import model_field_to_node_input
|
| 4 |
-
from comfy.comfy_types.node_typing import IO
|
| 5 |
-
from comfy_api_nodes.apis import (
|
| 6 |
-
TripoOrientation,
|
| 7 |
-
TripoModelVersion,
|
| 8 |
-
)
|
| 9 |
-
from comfy_api_nodes.apis.tripo_api import (
|
| 10 |
-
TripoTaskType,
|
| 11 |
-
TripoStyle,
|
| 12 |
-
TripoFileReference,
|
| 13 |
-
TripoFileEmptyReference,
|
| 14 |
-
TripoUrlReference,
|
| 15 |
-
TripoTaskResponse,
|
| 16 |
-
TripoTaskStatus,
|
| 17 |
-
TripoTextToModelRequest,
|
| 18 |
-
TripoImageToModelRequest,
|
| 19 |
-
TripoMultiviewToModelRequest,
|
| 20 |
-
TripoTextureModelRequest,
|
| 21 |
-
TripoRefineModelRequest,
|
| 22 |
-
TripoAnimateRigRequest,
|
| 23 |
-
TripoAnimateRetargetRequest,
|
| 24 |
-
TripoConvertModelRequest,
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
from comfy_api_nodes.apis.client import (
|
| 28 |
-
ApiEndpoint,
|
| 29 |
-
HttpMethod,
|
| 30 |
-
SynchronousOperation,
|
| 31 |
-
PollingOperation,
|
| 32 |
-
EmptyRequest,
|
| 33 |
-
)
|
| 34 |
-
from comfy_api_nodes.apinode_utils import (
|
| 35 |
-
upload_images_to_comfyapi,
|
| 36 |
-
download_url_to_bytesio,
|
| 37 |
-
)
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def upload_image_to_tripo(image, **kwargs):
|
| 41 |
-
urls = upload_images_to_comfyapi(image, max_images=1, auth_kwargs=kwargs)
|
| 42 |
-
return TripoFileReference(TripoUrlReference(url=urls[0], type="jpeg"))
|
| 43 |
-
|
| 44 |
-
def get_model_url_from_response(response: TripoTaskResponse) -> str:
|
| 45 |
-
if response.data is not None:
|
| 46 |
-
for key in ["pbr_model", "model", "base_model"]:
|
| 47 |
-
if getattr(response.data.output, key, None) is not None:
|
| 48 |
-
return getattr(response.data.output, key)
|
| 49 |
-
raise RuntimeError(f"Failed to get model url from response: {response}")
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def poll_until_finished(
|
| 53 |
-
kwargs: dict[str, str],
|
| 54 |
-
response: TripoTaskResponse,
|
| 55 |
-
) -> tuple[str, str]:
|
| 56 |
-
"""Polls the Tripo API endpoint until the task reaches a terminal state, then returns the response."""
|
| 57 |
-
if response.code != 0:
|
| 58 |
-
raise RuntimeError(f"Failed to generate mesh: {response.error}")
|
| 59 |
-
task_id = response.data.task_id
|
| 60 |
-
response_poll = PollingOperation(
|
| 61 |
-
poll_endpoint=ApiEndpoint(
|
| 62 |
-
path=f"/proxy/tripo/v2/openapi/task/{task_id}",
|
| 63 |
-
method=HttpMethod.GET,
|
| 64 |
-
request_model=EmptyRequest,
|
| 65 |
-
response_model=TripoTaskResponse,
|
| 66 |
-
),
|
| 67 |
-
completed_statuses=[TripoTaskStatus.SUCCESS],
|
| 68 |
-
failed_statuses=[
|
| 69 |
-
TripoTaskStatus.FAILED,
|
| 70 |
-
TripoTaskStatus.CANCELLED,
|
| 71 |
-
TripoTaskStatus.UNKNOWN,
|
| 72 |
-
TripoTaskStatus.BANNED,
|
| 73 |
-
TripoTaskStatus.EXPIRED,
|
| 74 |
-
],
|
| 75 |
-
status_extractor=lambda x: x.data.status,
|
| 76 |
-
auth_kwargs=kwargs,
|
| 77 |
-
node_id=kwargs["unique_id"],
|
| 78 |
-
result_url_extractor=get_model_url_from_response,
|
| 79 |
-
progress_extractor=lambda x: x.data.progress,
|
| 80 |
-
).execute()
|
| 81 |
-
if response_poll.data.status == TripoTaskStatus.SUCCESS:
|
| 82 |
-
url = get_model_url_from_response(response_poll)
|
| 83 |
-
bytesio = download_url_to_bytesio(url)
|
| 84 |
-
# Save the downloaded model file
|
| 85 |
-
model_file = f"tripo_model_{task_id}.glb"
|
| 86 |
-
with open(os.path.join(get_output_directory(), model_file), "wb") as f:
|
| 87 |
-
f.write(bytesio.getvalue())
|
| 88 |
-
return model_file, task_id
|
| 89 |
-
raise RuntimeError(f"Failed to generate mesh: {response_poll}")
|
| 90 |
-
|
| 91 |
-
class TripoTextToModelNode:
|
| 92 |
-
"""
|
| 93 |
-
Generates 3D models synchronously based on a text prompt using Tripo's API.
|
| 94 |
-
"""
|
| 95 |
-
AVERAGE_DURATION = 80
|
| 96 |
-
@classmethod
|
| 97 |
-
def INPUT_TYPES(s):
|
| 98 |
-
return {
|
| 99 |
-
"required": {
|
| 100 |
-
"prompt": ("STRING", {"multiline": True}),
|
| 101 |
-
},
|
| 102 |
-
"optional": {
|
| 103 |
-
"negative_prompt": ("STRING", {"multiline": True}),
|
| 104 |
-
"model_version": model_field_to_node_input(IO.COMBO, TripoTextToModelRequest, "model_version", enum_type=TripoModelVersion),
|
| 105 |
-
"style": model_field_to_node_input(IO.COMBO, TripoTextToModelRequest, "style", enum_type=TripoStyle, default="None"),
|
| 106 |
-
"texture": ("BOOLEAN", {"default": True}),
|
| 107 |
-
"pbr": ("BOOLEAN", {"default": True}),
|
| 108 |
-
"image_seed": ("INT", {"default": 42}),
|
| 109 |
-
"model_seed": ("INT", {"default": 42}),
|
| 110 |
-
"texture_seed": ("INT", {"default": 42}),
|
| 111 |
-
"texture_quality": (["standard", "detailed"], {"default": "standard"}),
|
| 112 |
-
"face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}),
|
| 113 |
-
"quad": ("BOOLEAN", {"default": False})
|
| 114 |
-
},
|
| 115 |
-
"hidden": {
|
| 116 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 117 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 118 |
-
"unique_id": "UNIQUE_ID",
|
| 119 |
-
},
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
RETURN_TYPES = ("STRING", "MODEL_TASK_ID",)
|
| 123 |
-
RETURN_NAMES = ("model_file", "model task_id")
|
| 124 |
-
FUNCTION = "generate_mesh"
|
| 125 |
-
CATEGORY = "api node/3d/Tripo"
|
| 126 |
-
API_NODE = True
|
| 127 |
-
OUTPUT_NODE = True
|
| 128 |
-
|
| 129 |
-
def generate_mesh(self, prompt, negative_prompt=None, model_version=None, style=None, texture=None, pbr=None, image_seed=None, model_seed=None, texture_seed=None, texture_quality=None, face_limit=None, quad=None, **kwargs):
|
| 130 |
-
style_enum = None if style == "None" else style
|
| 131 |
-
if not prompt:
|
| 132 |
-
raise RuntimeError("Prompt is required")
|
| 133 |
-
response = SynchronousOperation(
|
| 134 |
-
endpoint=ApiEndpoint(
|
| 135 |
-
path="/proxy/tripo/v2/openapi/task",
|
| 136 |
-
method=HttpMethod.POST,
|
| 137 |
-
request_model=TripoTextToModelRequest,
|
| 138 |
-
response_model=TripoTaskResponse,
|
| 139 |
-
),
|
| 140 |
-
request=TripoTextToModelRequest(
|
| 141 |
-
type=TripoTaskType.TEXT_TO_MODEL,
|
| 142 |
-
prompt=prompt,
|
| 143 |
-
negative_prompt=negative_prompt if negative_prompt else None,
|
| 144 |
-
model_version=model_version,
|
| 145 |
-
style=style_enum,
|
| 146 |
-
texture=texture,
|
| 147 |
-
pbr=pbr,
|
| 148 |
-
image_seed=image_seed,
|
| 149 |
-
model_seed=model_seed,
|
| 150 |
-
texture_seed=texture_seed,
|
| 151 |
-
texture_quality=texture_quality,
|
| 152 |
-
face_limit=face_limit,
|
| 153 |
-
auto_size=True,
|
| 154 |
-
quad=quad
|
| 155 |
-
),
|
| 156 |
-
auth_kwargs=kwargs,
|
| 157 |
-
).execute()
|
| 158 |
-
return poll_until_finished(kwargs, response)
|
| 159 |
-
|
| 160 |
-
class TripoImageToModelNode:
|
| 161 |
-
"""
|
| 162 |
-
Generates 3D models synchronously based on a single image using Tripo's API.
|
| 163 |
-
"""
|
| 164 |
-
AVERAGE_DURATION = 80
|
| 165 |
-
@classmethod
|
| 166 |
-
def INPUT_TYPES(s):
|
| 167 |
-
return {
|
| 168 |
-
"required": {
|
| 169 |
-
"image": ("IMAGE",),
|
| 170 |
-
},
|
| 171 |
-
"optional": {
|
| 172 |
-
"model_version": model_field_to_node_input(IO.COMBO, TripoImageToModelRequest, "model_version", enum_type=TripoModelVersion),
|
| 173 |
-
"style": model_field_to_node_input(IO.COMBO, TripoTextToModelRequest, "style", enum_type=TripoStyle, default="None"),
|
| 174 |
-
"texture": ("BOOLEAN", {"default": True}),
|
| 175 |
-
"pbr": ("BOOLEAN", {"default": True}),
|
| 176 |
-
"model_seed": ("INT", {"default": 42}),
|
| 177 |
-
"orientation": model_field_to_node_input(IO.COMBO, TripoImageToModelRequest, "orientation", enum_type=TripoOrientation),
|
| 178 |
-
"texture_seed": ("INT", {"default": 42}),
|
| 179 |
-
"texture_quality": (["standard", "detailed"], {"default": "standard"}),
|
| 180 |
-
"texture_alignment": (["original_image", "geometry"], {"default": "original_image"}),
|
| 181 |
-
"face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}),
|
| 182 |
-
"quad": ("BOOLEAN", {"default": False})
|
| 183 |
-
},
|
| 184 |
-
"hidden": {
|
| 185 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 186 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 187 |
-
"unique_id": "UNIQUE_ID",
|
| 188 |
-
},
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
RETURN_TYPES = ("STRING", "MODEL_TASK_ID",)
|
| 192 |
-
RETURN_NAMES = ("model_file", "model task_id")
|
| 193 |
-
FUNCTION = "generate_mesh"
|
| 194 |
-
CATEGORY = "api node/3d/Tripo"
|
| 195 |
-
API_NODE = True
|
| 196 |
-
OUTPUT_NODE = True
|
| 197 |
-
|
| 198 |
-
def generate_mesh(self, image, model_version=None, style=None, texture=None, pbr=None, model_seed=None, orientation=None, texture_alignment=None, texture_seed=None, texture_quality=None, face_limit=None, quad=None, **kwargs):
|
| 199 |
-
style_enum = None if style == "None" else style
|
| 200 |
-
if image is None:
|
| 201 |
-
raise RuntimeError("Image is required")
|
| 202 |
-
tripo_file = upload_image_to_tripo(image, **kwargs)
|
| 203 |
-
response = SynchronousOperation(
|
| 204 |
-
endpoint=ApiEndpoint(
|
| 205 |
-
path="/proxy/tripo/v2/openapi/task",
|
| 206 |
-
method=HttpMethod.POST,
|
| 207 |
-
request_model=TripoImageToModelRequest,
|
| 208 |
-
response_model=TripoTaskResponse,
|
| 209 |
-
),
|
| 210 |
-
request=TripoImageToModelRequest(
|
| 211 |
-
type=TripoTaskType.IMAGE_TO_MODEL,
|
| 212 |
-
file=tripo_file,
|
| 213 |
-
model_version=model_version,
|
| 214 |
-
style=style_enum,
|
| 215 |
-
texture=texture,
|
| 216 |
-
pbr=pbr,
|
| 217 |
-
model_seed=model_seed,
|
| 218 |
-
orientation=orientation,
|
| 219 |
-
texture_alignment=texture_alignment,
|
| 220 |
-
texture_seed=texture_seed,
|
| 221 |
-
texture_quality=texture_quality,
|
| 222 |
-
face_limit=face_limit,
|
| 223 |
-
auto_size=True,
|
| 224 |
-
quad=quad
|
| 225 |
-
),
|
| 226 |
-
auth_kwargs=kwargs,
|
| 227 |
-
).execute()
|
| 228 |
-
return poll_until_finished(kwargs, response)
|
| 229 |
-
|
| 230 |
-
class TripoMultiviewToModelNode:
|
| 231 |
-
"""
|
| 232 |
-
Generates 3D models synchronously based on up to four images (front, left, back, right) using Tripo's API.
|
| 233 |
-
"""
|
| 234 |
-
AVERAGE_DURATION = 80
|
| 235 |
-
@classmethod
|
| 236 |
-
def INPUT_TYPES(s):
|
| 237 |
-
return {
|
| 238 |
-
"required": {
|
| 239 |
-
"image": ("IMAGE",),
|
| 240 |
-
},
|
| 241 |
-
"optional": {
|
| 242 |
-
"image_left": ("IMAGE",),
|
| 243 |
-
"image_back": ("IMAGE",),
|
| 244 |
-
"image_right": ("IMAGE",),
|
| 245 |
-
"model_version": model_field_to_node_input(IO.COMBO, TripoMultiviewToModelRequest, "model_version", enum_type=TripoModelVersion),
|
| 246 |
-
"orientation": model_field_to_node_input(IO.COMBO, TripoImageToModelRequest, "orientation", enum_type=TripoOrientation),
|
| 247 |
-
"texture": ("BOOLEAN", {"default": True}),
|
| 248 |
-
"pbr": ("BOOLEAN", {"default": True}),
|
| 249 |
-
"model_seed": ("INT", {"default": 42}),
|
| 250 |
-
"texture_seed": ("INT", {"default": 42}),
|
| 251 |
-
"texture_quality": (["standard", "detailed"], {"default": "standard"}),
|
| 252 |
-
"texture_alignment": (["original_image", "geometry"], {"default": "original_image"}),
|
| 253 |
-
"face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}),
|
| 254 |
-
"quad": ("BOOLEAN", {"default": False})
|
| 255 |
-
},
|
| 256 |
-
"hidden": {
|
| 257 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 258 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 259 |
-
"unique_id": "UNIQUE_ID",
|
| 260 |
-
},
|
| 261 |
-
}
|
| 262 |
-
|
| 263 |
-
RETURN_TYPES = ("STRING", "MODEL_TASK_ID",)
|
| 264 |
-
RETURN_NAMES = ("model_file", "model task_id")
|
| 265 |
-
FUNCTION = "generate_mesh"
|
| 266 |
-
CATEGORY = "api node/3d/Tripo"
|
| 267 |
-
API_NODE = True
|
| 268 |
-
OUTPUT_NODE = True
|
| 269 |
-
|
| 270 |
-
def generate_mesh(self, image, image_left=None, image_back=None, image_right=None, model_version=None, orientation=None, texture=None, pbr=None, model_seed=None, texture_seed=None, texture_quality=None, texture_alignment=None, face_limit=None, quad=None, **kwargs):
|
| 271 |
-
if image is None:
|
| 272 |
-
raise RuntimeError("front image for multiview is required")
|
| 273 |
-
images = []
|
| 274 |
-
image_dict = {
|
| 275 |
-
"image": image,
|
| 276 |
-
"image_left": image_left,
|
| 277 |
-
"image_back": image_back,
|
| 278 |
-
"image_right": image_right
|
| 279 |
-
}
|
| 280 |
-
if image_left is None and image_back is None and image_right is None:
|
| 281 |
-
raise RuntimeError("At least one of left, back, or right image must be provided for multiview")
|
| 282 |
-
for image_name in ["image", "image_left", "image_back", "image_right"]:
|
| 283 |
-
image_ = image_dict[image_name]
|
| 284 |
-
if image_ is not None:
|
| 285 |
-
tripo_file = upload_image_to_tripo(image_, **kwargs)
|
| 286 |
-
images.append(tripo_file)
|
| 287 |
-
else:
|
| 288 |
-
images.append(TripoFileEmptyReference())
|
| 289 |
-
response = SynchronousOperation(
|
| 290 |
-
endpoint=ApiEndpoint(
|
| 291 |
-
path="/proxy/tripo/v2/openapi/task",
|
| 292 |
-
method=HttpMethod.POST,
|
| 293 |
-
request_model=TripoMultiviewToModelRequest,
|
| 294 |
-
response_model=TripoTaskResponse,
|
| 295 |
-
),
|
| 296 |
-
request=TripoMultiviewToModelRequest(
|
| 297 |
-
type=TripoTaskType.MULTIVIEW_TO_MODEL,
|
| 298 |
-
files=images,
|
| 299 |
-
model_version=model_version,
|
| 300 |
-
orientation=orientation,
|
| 301 |
-
texture=texture,
|
| 302 |
-
pbr=pbr,
|
| 303 |
-
model_seed=model_seed,
|
| 304 |
-
texture_seed=texture_seed,
|
| 305 |
-
texture_quality=texture_quality,
|
| 306 |
-
texture_alignment=texture_alignment,
|
| 307 |
-
face_limit=face_limit,
|
| 308 |
-
quad=quad,
|
| 309 |
-
),
|
| 310 |
-
auth_kwargs=kwargs,
|
| 311 |
-
).execute()
|
| 312 |
-
return poll_until_finished(kwargs, response)
|
| 313 |
-
|
| 314 |
-
class TripoTextureNode:
|
| 315 |
-
@classmethod
|
| 316 |
-
def INPUT_TYPES(s):
|
| 317 |
-
return {
|
| 318 |
-
"required": {
|
| 319 |
-
"model_task_id": ("MODEL_TASK_ID",),
|
| 320 |
-
},
|
| 321 |
-
"optional": {
|
| 322 |
-
"texture": ("BOOLEAN", {"default": True}),
|
| 323 |
-
"pbr": ("BOOLEAN", {"default": True}),
|
| 324 |
-
"texture_seed": ("INT", {"default": 42}),
|
| 325 |
-
"texture_quality": (["standard", "detailed"], {"default": "standard"}),
|
| 326 |
-
"texture_alignment": (["original_image", "geometry"], {"default": "original_image"}),
|
| 327 |
-
},
|
| 328 |
-
"hidden": {
|
| 329 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 330 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 331 |
-
"unique_id": "UNIQUE_ID",
|
| 332 |
-
},
|
| 333 |
-
}
|
| 334 |
-
|
| 335 |
-
RETURN_TYPES = ("STRING", "MODEL_TASK_ID",)
|
| 336 |
-
RETURN_NAMES = ("model_file", "model task_id")
|
| 337 |
-
FUNCTION = "generate_mesh"
|
| 338 |
-
CATEGORY = "api node/3d/Tripo"
|
| 339 |
-
API_NODE = True
|
| 340 |
-
OUTPUT_NODE = True
|
| 341 |
-
AVERAGE_DURATION = 80
|
| 342 |
-
|
| 343 |
-
def generate_mesh(self, model_task_id, texture=None, pbr=None, texture_seed=None, texture_quality=None, texture_alignment=None, **kwargs):
|
| 344 |
-
response = SynchronousOperation(
|
| 345 |
-
endpoint=ApiEndpoint(
|
| 346 |
-
path="/proxy/tripo/v2/openapi/task",
|
| 347 |
-
method=HttpMethod.POST,
|
| 348 |
-
request_model=TripoTextureModelRequest,
|
| 349 |
-
response_model=TripoTaskResponse,
|
| 350 |
-
),
|
| 351 |
-
request=TripoTextureModelRequest(
|
| 352 |
-
original_model_task_id=model_task_id,
|
| 353 |
-
texture=texture,
|
| 354 |
-
pbr=pbr,
|
| 355 |
-
texture_seed=texture_seed,
|
| 356 |
-
texture_quality=texture_quality,
|
| 357 |
-
texture_alignment=texture_alignment
|
| 358 |
-
),
|
| 359 |
-
auth_kwargs=kwargs,
|
| 360 |
-
).execute()
|
| 361 |
-
return poll_until_finished(kwargs, response)
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
class TripoRefineNode:
|
| 365 |
-
@classmethod
|
| 366 |
-
def INPUT_TYPES(s):
|
| 367 |
-
return {
|
| 368 |
-
"required": {
|
| 369 |
-
"model_task_id": ("MODEL_TASK_ID", {
|
| 370 |
-
"tooltip": "Must be a v1.4 Tripo model"
|
| 371 |
-
}),
|
| 372 |
-
},
|
| 373 |
-
"hidden": {
|
| 374 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 375 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 376 |
-
"unique_id": "UNIQUE_ID",
|
| 377 |
-
},
|
| 378 |
-
}
|
| 379 |
-
|
| 380 |
-
DESCRIPTION = "Refine a draft model created by v1.4 Tripo models only."
|
| 381 |
-
|
| 382 |
-
RETURN_TYPES = ("STRING", "MODEL_TASK_ID",)
|
| 383 |
-
RETURN_NAMES = ("model_file", "model task_id")
|
| 384 |
-
FUNCTION = "generate_mesh"
|
| 385 |
-
CATEGORY = "api node/3d/Tripo"
|
| 386 |
-
API_NODE = True
|
| 387 |
-
OUTPUT_NODE = True
|
| 388 |
-
AVERAGE_DURATION = 240
|
| 389 |
-
|
| 390 |
-
def generate_mesh(self, model_task_id, **kwargs):
|
| 391 |
-
response = SynchronousOperation(
|
| 392 |
-
endpoint=ApiEndpoint(
|
| 393 |
-
path="/proxy/tripo/v2/openapi/task",
|
| 394 |
-
method=HttpMethod.POST,
|
| 395 |
-
request_model=TripoRefineModelRequest,
|
| 396 |
-
response_model=TripoTaskResponse,
|
| 397 |
-
),
|
| 398 |
-
request=TripoRefineModelRequest(
|
| 399 |
-
draft_model_task_id=model_task_id
|
| 400 |
-
),
|
| 401 |
-
auth_kwargs=kwargs,
|
| 402 |
-
).execute()
|
| 403 |
-
return poll_until_finished(kwargs, response)
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
class TripoRigNode:
|
| 407 |
-
@classmethod
|
| 408 |
-
def INPUT_TYPES(s):
|
| 409 |
-
return {
|
| 410 |
-
"required": {
|
| 411 |
-
"original_model_task_id": ("MODEL_TASK_ID",),
|
| 412 |
-
},
|
| 413 |
-
"hidden": {
|
| 414 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 415 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 416 |
-
"unique_id": "UNIQUE_ID",
|
| 417 |
-
},
|
| 418 |
-
}
|
| 419 |
-
|
| 420 |
-
RETURN_TYPES = ("STRING", "RIG_TASK_ID")
|
| 421 |
-
RETURN_NAMES = ("model_file", "rig task_id")
|
| 422 |
-
FUNCTION = "generate_mesh"
|
| 423 |
-
CATEGORY = "api node/3d/Tripo"
|
| 424 |
-
API_NODE = True
|
| 425 |
-
OUTPUT_NODE = True
|
| 426 |
-
AVERAGE_DURATION = 180
|
| 427 |
-
|
| 428 |
-
def generate_mesh(self, original_model_task_id, **kwargs):
|
| 429 |
-
response = SynchronousOperation(
|
| 430 |
-
endpoint=ApiEndpoint(
|
| 431 |
-
path="/proxy/tripo/v2/openapi/task",
|
| 432 |
-
method=HttpMethod.POST,
|
| 433 |
-
request_model=TripoAnimateRigRequest,
|
| 434 |
-
response_model=TripoTaskResponse,
|
| 435 |
-
),
|
| 436 |
-
request=TripoAnimateRigRequest(
|
| 437 |
-
original_model_task_id=original_model_task_id,
|
| 438 |
-
out_format="glb",
|
| 439 |
-
spec="tripo"
|
| 440 |
-
),
|
| 441 |
-
auth_kwargs=kwargs,
|
| 442 |
-
).execute()
|
| 443 |
-
return poll_until_finished(kwargs, response)
|
| 444 |
-
|
| 445 |
-
class TripoRetargetNode:
|
| 446 |
-
@classmethod
|
| 447 |
-
def INPUT_TYPES(s):
|
| 448 |
-
return {
|
| 449 |
-
"required": {
|
| 450 |
-
"original_model_task_id": ("RIG_TASK_ID",),
|
| 451 |
-
"animation": ([
|
| 452 |
-
"preset:idle",
|
| 453 |
-
"preset:walk",
|
| 454 |
-
"preset:climb",
|
| 455 |
-
"preset:jump",
|
| 456 |
-
"preset:slash",
|
| 457 |
-
"preset:shoot",
|
| 458 |
-
"preset:hurt",
|
| 459 |
-
"preset:fall",
|
| 460 |
-
"preset:turn",
|
| 461 |
-
],),
|
| 462 |
-
},
|
| 463 |
-
"hidden": {
|
| 464 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 465 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 466 |
-
"unique_id": "UNIQUE_ID",
|
| 467 |
-
},
|
| 468 |
-
}
|
| 469 |
-
|
| 470 |
-
RETURN_TYPES = ("STRING", "RETARGET_TASK_ID")
|
| 471 |
-
RETURN_NAMES = ("model_file", "retarget task_id")
|
| 472 |
-
FUNCTION = "generate_mesh"
|
| 473 |
-
CATEGORY = "api node/3d/Tripo"
|
| 474 |
-
API_NODE = True
|
| 475 |
-
OUTPUT_NODE = True
|
| 476 |
-
AVERAGE_DURATION = 30
|
| 477 |
-
|
| 478 |
-
def generate_mesh(self, animation, original_model_task_id, **kwargs):
|
| 479 |
-
response = SynchronousOperation(
|
| 480 |
-
endpoint=ApiEndpoint(
|
| 481 |
-
path="/proxy/tripo/v2/openapi/task",
|
| 482 |
-
method=HttpMethod.POST,
|
| 483 |
-
request_model=TripoAnimateRetargetRequest,
|
| 484 |
-
response_model=TripoTaskResponse,
|
| 485 |
-
),
|
| 486 |
-
request=TripoAnimateRetargetRequest(
|
| 487 |
-
original_model_task_id=original_model_task_id,
|
| 488 |
-
animation=animation,
|
| 489 |
-
out_format="glb",
|
| 490 |
-
bake_animation=True
|
| 491 |
-
),
|
| 492 |
-
auth_kwargs=kwargs,
|
| 493 |
-
).execute()
|
| 494 |
-
return poll_until_finished(kwargs, response)
|
| 495 |
-
|
| 496 |
-
class TripoConversionNode:
|
| 497 |
-
@classmethod
|
| 498 |
-
def INPUT_TYPES(s):
|
| 499 |
-
return {
|
| 500 |
-
"required": {
|
| 501 |
-
"original_model_task_id": ("MODEL_TASK_ID,RIG_TASK_ID,RETARGET_TASK_ID",),
|
| 502 |
-
"format": (["GLTF", "USDZ", "FBX", "OBJ", "STL", "3MF"],),
|
| 503 |
-
},
|
| 504 |
-
"optional": {
|
| 505 |
-
"quad": ("BOOLEAN", {"default": False}),
|
| 506 |
-
"face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}),
|
| 507 |
-
"texture_size": ("INT", {"min": 128, "max": 4096, "default": 4096}),
|
| 508 |
-
"texture_format": (["BMP", "DPX", "HDR", "JPEG", "OPEN_EXR", "PNG", "TARGA", "TIFF", "WEBP"], {"default": "JPEG"})
|
| 509 |
-
},
|
| 510 |
-
"hidden": {
|
| 511 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 512 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 513 |
-
"unique_id": "UNIQUE_ID",
|
| 514 |
-
},
|
| 515 |
-
}
|
| 516 |
-
|
| 517 |
-
@classmethod
|
| 518 |
-
def VALIDATE_INPUTS(cls, input_types):
|
| 519 |
-
# The min and max of input1 and input2 are still validated because
|
| 520 |
-
# we didn't take `input1` or `input2` as arguments
|
| 521 |
-
if input_types["original_model_task_id"] not in ("MODEL_TASK_ID", "RIG_TASK_ID", "RETARGET_TASK_ID"):
|
| 522 |
-
return "original_model_task_id must be MODEL_TASK_ID, RIG_TASK_ID or RETARGET_TASK_ID type"
|
| 523 |
-
return True
|
| 524 |
-
|
| 525 |
-
RETURN_TYPES = ()
|
| 526 |
-
FUNCTION = "generate_mesh"
|
| 527 |
-
CATEGORY = "api node/3d/Tripo"
|
| 528 |
-
API_NODE = True
|
| 529 |
-
OUTPUT_NODE = True
|
| 530 |
-
AVERAGE_DURATION = 30
|
| 531 |
-
|
| 532 |
-
def generate_mesh(self, original_model_task_id, format, quad, face_limit, texture_size, texture_format, **kwargs):
|
| 533 |
-
if not original_model_task_id:
|
| 534 |
-
raise RuntimeError("original_model_task_id is required")
|
| 535 |
-
response = SynchronousOperation(
|
| 536 |
-
endpoint=ApiEndpoint(
|
| 537 |
-
path="/proxy/tripo/v2/openapi/task",
|
| 538 |
-
method=HttpMethod.POST,
|
| 539 |
-
request_model=TripoConvertModelRequest,
|
| 540 |
-
response_model=TripoTaskResponse,
|
| 541 |
-
),
|
| 542 |
-
request=TripoConvertModelRequest(
|
| 543 |
-
original_model_task_id=original_model_task_id,
|
| 544 |
-
format=format,
|
| 545 |
-
quad=quad if quad else None,
|
| 546 |
-
face_limit=face_limit if face_limit != -1 else None,
|
| 547 |
-
texture_size=texture_size if texture_size != 4096 else None,
|
| 548 |
-
texture_format=texture_format if texture_format != "JPEG" else None
|
| 549 |
-
),
|
| 550 |
-
auth_kwargs=kwargs,
|
| 551 |
-
).execute()
|
| 552 |
-
return poll_until_finished(kwargs, response)
|
| 553 |
-
|
| 554 |
-
NODE_CLASS_MAPPINGS = {
|
| 555 |
-
"TripoTextToModelNode": TripoTextToModelNode,
|
| 556 |
-
"TripoImageToModelNode": TripoImageToModelNode,
|
| 557 |
-
"TripoMultiviewToModelNode": TripoMultiviewToModelNode,
|
| 558 |
-
"TripoTextureNode": TripoTextureNode,
|
| 559 |
-
"TripoRefineNode": TripoRefineNode,
|
| 560 |
-
"TripoRigNode": TripoRigNode,
|
| 561 |
-
"TripoRetargetNode": TripoRetargetNode,
|
| 562 |
-
"TripoConversionNode": TripoConversionNode,
|
| 563 |
-
}
|
| 564 |
-
|
| 565 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 566 |
-
"TripoTextToModelNode": "Tripo: Text to Model",
|
| 567 |
-
"TripoImageToModelNode": "Tripo: Image to Model",
|
| 568 |
-
"TripoMultiviewToModelNode": "Tripo: Multiview to Model",
|
| 569 |
-
"TripoTextureNode": "Tripo: Texture model",
|
| 570 |
-
"TripoRefineNode": "Tripo: Refine Draft model",
|
| 571 |
-
"TripoRigNode": "Tripo: Rig model",
|
| 572 |
-
"TripoRetargetNode": "Tripo: Retarget rigged model",
|
| 573 |
-
"TripoConversionNode": "Tripo: Convert model",
|
| 574 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/nodes_veo2.py
DELETED
|
@@ -1,308 +0,0 @@
|
|
| 1 |
-
import io
|
| 2 |
-
import logging
|
| 3 |
-
import base64
|
| 4 |
-
import requests
|
| 5 |
-
import torch
|
| 6 |
-
from typing import Optional
|
| 7 |
-
|
| 8 |
-
from comfy.comfy_types.node_typing import IO, ComfyNodeABC
|
| 9 |
-
from comfy_api.input_impl.video_types import VideoFromFile
|
| 10 |
-
from comfy_api_nodes.apis import (
|
| 11 |
-
Veo2GenVidRequest,
|
| 12 |
-
Veo2GenVidResponse,
|
| 13 |
-
Veo2GenVidPollRequest,
|
| 14 |
-
Veo2GenVidPollResponse
|
| 15 |
-
)
|
| 16 |
-
from comfy_api_nodes.apis.client import (
|
| 17 |
-
ApiEndpoint,
|
| 18 |
-
HttpMethod,
|
| 19 |
-
SynchronousOperation,
|
| 20 |
-
PollingOperation,
|
| 21 |
-
)
|
| 22 |
-
|
| 23 |
-
from comfy_api_nodes.apinode_utils import (
|
| 24 |
-
downscale_image_tensor,
|
| 25 |
-
tensor_to_base64_string
|
| 26 |
-
)
|
| 27 |
-
|
| 28 |
-
AVERAGE_DURATION_VIDEO_GEN = 32
|
| 29 |
-
|
| 30 |
-
def convert_image_to_base64(image: torch.Tensor):
|
| 31 |
-
if image is None:
|
| 32 |
-
return None
|
| 33 |
-
|
| 34 |
-
scaled_image = downscale_image_tensor(image, total_pixels=2048*2048)
|
| 35 |
-
return tensor_to_base64_string(scaled_image)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def get_video_url_from_response(poll_response: Veo2GenVidPollResponse) -> Optional[str]:
|
| 39 |
-
if (
|
| 40 |
-
poll_response.response
|
| 41 |
-
and hasattr(poll_response.response, "videos")
|
| 42 |
-
and poll_response.response.videos
|
| 43 |
-
and len(poll_response.response.videos) > 0
|
| 44 |
-
):
|
| 45 |
-
video = poll_response.response.videos[0]
|
| 46 |
-
else:
|
| 47 |
-
return None
|
| 48 |
-
if hasattr(video, "gcsUri") and video.gcsUri:
|
| 49 |
-
return str(video.gcsUri)
|
| 50 |
-
return None
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
class VeoVideoGenerationNode(ComfyNodeABC):
|
| 54 |
-
"""
|
| 55 |
-
Generates videos from text prompts using Google's Veo API.
|
| 56 |
-
|
| 57 |
-
This node can create videos from text descriptions and optional image inputs,
|
| 58 |
-
with control over parameters like aspect ratio, duration, and more.
|
| 59 |
-
"""
|
| 60 |
-
|
| 61 |
-
@classmethod
|
| 62 |
-
def INPUT_TYPES(s):
|
| 63 |
-
return {
|
| 64 |
-
"required": {
|
| 65 |
-
"prompt": (
|
| 66 |
-
IO.STRING,
|
| 67 |
-
{
|
| 68 |
-
"multiline": True,
|
| 69 |
-
"default": "",
|
| 70 |
-
"tooltip": "Text description of the video",
|
| 71 |
-
},
|
| 72 |
-
),
|
| 73 |
-
"aspect_ratio": (
|
| 74 |
-
IO.COMBO,
|
| 75 |
-
{
|
| 76 |
-
"options": ["16:9", "9:16"],
|
| 77 |
-
"default": "16:9",
|
| 78 |
-
"tooltip": "Aspect ratio of the output video",
|
| 79 |
-
},
|
| 80 |
-
),
|
| 81 |
-
},
|
| 82 |
-
"optional": {
|
| 83 |
-
"negative_prompt": (
|
| 84 |
-
IO.STRING,
|
| 85 |
-
{
|
| 86 |
-
"multiline": True,
|
| 87 |
-
"default": "",
|
| 88 |
-
"tooltip": "Negative text prompt to guide what to avoid in the video",
|
| 89 |
-
},
|
| 90 |
-
),
|
| 91 |
-
"duration_seconds": (
|
| 92 |
-
IO.INT,
|
| 93 |
-
{
|
| 94 |
-
"default": 5,
|
| 95 |
-
"min": 5,
|
| 96 |
-
"max": 8,
|
| 97 |
-
"step": 1,
|
| 98 |
-
"display": "number",
|
| 99 |
-
"tooltip": "Duration of the output video in seconds",
|
| 100 |
-
},
|
| 101 |
-
),
|
| 102 |
-
"enhance_prompt": (
|
| 103 |
-
IO.BOOLEAN,
|
| 104 |
-
{
|
| 105 |
-
"default": True,
|
| 106 |
-
"tooltip": "Whether to enhance the prompt with AI assistance",
|
| 107 |
-
}
|
| 108 |
-
),
|
| 109 |
-
"person_generation": (
|
| 110 |
-
IO.COMBO,
|
| 111 |
-
{
|
| 112 |
-
"options": ["ALLOW", "BLOCK"],
|
| 113 |
-
"default": "ALLOW",
|
| 114 |
-
"tooltip": "Whether to allow generating people in the video",
|
| 115 |
-
},
|
| 116 |
-
),
|
| 117 |
-
"seed": (
|
| 118 |
-
IO.INT,
|
| 119 |
-
{
|
| 120 |
-
"default": 0,
|
| 121 |
-
"min": 0,
|
| 122 |
-
"max": 0xFFFFFFFF,
|
| 123 |
-
"step": 1,
|
| 124 |
-
"display": "number",
|
| 125 |
-
"control_after_generate": True,
|
| 126 |
-
"tooltip": "Seed for video generation (0 for random)",
|
| 127 |
-
},
|
| 128 |
-
),
|
| 129 |
-
"image": (IO.IMAGE, {
|
| 130 |
-
"default": None,
|
| 131 |
-
"tooltip": "Optional reference image to guide video generation",
|
| 132 |
-
}),
|
| 133 |
-
},
|
| 134 |
-
"hidden": {
|
| 135 |
-
"auth_token": "AUTH_TOKEN_COMFY_ORG",
|
| 136 |
-
"comfy_api_key": "API_KEY_COMFY_ORG",
|
| 137 |
-
"unique_id": "UNIQUE_ID",
|
| 138 |
-
},
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
RETURN_TYPES = (IO.VIDEO,)
|
| 142 |
-
FUNCTION = "generate_video"
|
| 143 |
-
CATEGORY = "api node/video/Veo"
|
| 144 |
-
DESCRIPTION = "Generates videos from text prompts using Google's Veo API"
|
| 145 |
-
API_NODE = True
|
| 146 |
-
|
| 147 |
-
def generate_video(
|
| 148 |
-
self,
|
| 149 |
-
prompt,
|
| 150 |
-
aspect_ratio="16:9",
|
| 151 |
-
negative_prompt="",
|
| 152 |
-
duration_seconds=5,
|
| 153 |
-
enhance_prompt=True,
|
| 154 |
-
person_generation="ALLOW",
|
| 155 |
-
seed=0,
|
| 156 |
-
image=None,
|
| 157 |
-
unique_id: Optional[str] = None,
|
| 158 |
-
**kwargs,
|
| 159 |
-
):
|
| 160 |
-
# Prepare the instances for the request
|
| 161 |
-
instances = []
|
| 162 |
-
|
| 163 |
-
instance = {
|
| 164 |
-
"prompt": prompt
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
# Add image if provided
|
| 168 |
-
if image is not None:
|
| 169 |
-
image_base64 = convert_image_to_base64(image)
|
| 170 |
-
if image_base64:
|
| 171 |
-
instance["image"] = {
|
| 172 |
-
"bytesBase64Encoded": image_base64,
|
| 173 |
-
"mimeType": "image/png"
|
| 174 |
-
}
|
| 175 |
-
|
| 176 |
-
instances.append(instance)
|
| 177 |
-
|
| 178 |
-
# Create parameters dictionary
|
| 179 |
-
parameters = {
|
| 180 |
-
"aspectRatio": aspect_ratio,
|
| 181 |
-
"personGeneration": person_generation,
|
| 182 |
-
"durationSeconds": duration_seconds,
|
| 183 |
-
"enhancePrompt": enhance_prompt,
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
# Add optional parameters if provided
|
| 187 |
-
if negative_prompt:
|
| 188 |
-
parameters["negativePrompt"] = negative_prompt
|
| 189 |
-
if seed > 0:
|
| 190 |
-
parameters["seed"] = seed
|
| 191 |
-
|
| 192 |
-
# Initial request to start video generation
|
| 193 |
-
initial_operation = SynchronousOperation(
|
| 194 |
-
endpoint=ApiEndpoint(
|
| 195 |
-
path="/proxy/veo/generate",
|
| 196 |
-
method=HttpMethod.POST,
|
| 197 |
-
request_model=Veo2GenVidRequest,
|
| 198 |
-
response_model=Veo2GenVidResponse
|
| 199 |
-
),
|
| 200 |
-
request=Veo2GenVidRequest(
|
| 201 |
-
instances=instances,
|
| 202 |
-
parameters=parameters
|
| 203 |
-
),
|
| 204 |
-
auth_kwargs=kwargs,
|
| 205 |
-
)
|
| 206 |
-
|
| 207 |
-
initial_response = initial_operation.execute()
|
| 208 |
-
operation_name = initial_response.name
|
| 209 |
-
|
| 210 |
-
logging.info(f"Veo generation started with operation name: {operation_name}")
|
| 211 |
-
|
| 212 |
-
# Define status extractor function
|
| 213 |
-
def status_extractor(response):
|
| 214 |
-
# Only return "completed" if the operation is done, regardless of success or failure
|
| 215 |
-
# We'll check for errors after polling completes
|
| 216 |
-
return "completed" if response.done else "pending"
|
| 217 |
-
|
| 218 |
-
# Define progress extractor function
|
| 219 |
-
def progress_extractor(response):
|
| 220 |
-
# Could be enhanced if the API provides progress information
|
| 221 |
-
return None
|
| 222 |
-
|
| 223 |
-
# Define the polling operation
|
| 224 |
-
poll_operation = PollingOperation(
|
| 225 |
-
poll_endpoint=ApiEndpoint(
|
| 226 |
-
path="/proxy/veo/poll",
|
| 227 |
-
method=HttpMethod.POST,
|
| 228 |
-
request_model=Veo2GenVidPollRequest,
|
| 229 |
-
response_model=Veo2GenVidPollResponse
|
| 230 |
-
),
|
| 231 |
-
completed_statuses=["completed"],
|
| 232 |
-
failed_statuses=[], # No failed statuses, we'll handle errors after polling
|
| 233 |
-
status_extractor=status_extractor,
|
| 234 |
-
progress_extractor=progress_extractor,
|
| 235 |
-
request=Veo2GenVidPollRequest(
|
| 236 |
-
operationName=operation_name
|
| 237 |
-
),
|
| 238 |
-
auth_kwargs=kwargs,
|
| 239 |
-
poll_interval=5.0,
|
| 240 |
-
result_url_extractor=get_video_url_from_response,
|
| 241 |
-
node_id=unique_id,
|
| 242 |
-
estimated_duration=AVERAGE_DURATION_VIDEO_GEN,
|
| 243 |
-
)
|
| 244 |
-
|
| 245 |
-
# Execute the polling operation
|
| 246 |
-
poll_response = poll_operation.execute()
|
| 247 |
-
|
| 248 |
-
# Now check for errors in the final response
|
| 249 |
-
# Check for error in poll response
|
| 250 |
-
if hasattr(poll_response, 'error') and poll_response.error:
|
| 251 |
-
error_message = f"Veo API error: {poll_response.error.message} (code: {poll_response.error.code})"
|
| 252 |
-
logging.error(error_message)
|
| 253 |
-
raise Exception(error_message)
|
| 254 |
-
|
| 255 |
-
# Check for RAI filtered content
|
| 256 |
-
if (hasattr(poll_response.response, 'raiMediaFilteredCount') and
|
| 257 |
-
poll_response.response.raiMediaFilteredCount > 0):
|
| 258 |
-
|
| 259 |
-
# Extract reason message if available
|
| 260 |
-
if (hasattr(poll_response.response, 'raiMediaFilteredReasons') and
|
| 261 |
-
poll_response.response.raiMediaFilteredReasons):
|
| 262 |
-
reason = poll_response.response.raiMediaFilteredReasons[0]
|
| 263 |
-
error_message = f"Content filtered by Google's Responsible AI practices: {reason} ({poll_response.response.raiMediaFilteredCount} videos filtered.)"
|
| 264 |
-
else:
|
| 265 |
-
error_message = f"Content filtered by Google's Responsible AI practices ({poll_response.response.raiMediaFilteredCount} videos filtered.)"
|
| 266 |
-
|
| 267 |
-
logging.error(error_message)
|
| 268 |
-
raise Exception(error_message)
|
| 269 |
-
|
| 270 |
-
# Extract video data
|
| 271 |
-
video_data = None
|
| 272 |
-
if poll_response.response and hasattr(poll_response.response, 'videos') and poll_response.response.videos and len(poll_response.response.videos) > 0:
|
| 273 |
-
video = poll_response.response.videos[0]
|
| 274 |
-
|
| 275 |
-
# Check if video is provided as base64 or URL
|
| 276 |
-
if hasattr(video, 'bytesBase64Encoded') and video.bytesBase64Encoded:
|
| 277 |
-
# Decode base64 string to bytes
|
| 278 |
-
video_data = base64.b64decode(video.bytesBase64Encoded)
|
| 279 |
-
elif hasattr(video, 'gcsUri') and video.gcsUri:
|
| 280 |
-
# Download from URL
|
| 281 |
-
video_url = video.gcsUri
|
| 282 |
-
video_response = requests.get(video_url)
|
| 283 |
-
video_data = video_response.content
|
| 284 |
-
else:
|
| 285 |
-
raise Exception("Video returned but no data or URL was provided")
|
| 286 |
-
else:
|
| 287 |
-
raise Exception("Video generation completed but no video was returned")
|
| 288 |
-
|
| 289 |
-
if not video_data:
|
| 290 |
-
raise Exception("No video data was returned")
|
| 291 |
-
|
| 292 |
-
logging.info("Video generation completed successfully")
|
| 293 |
-
|
| 294 |
-
# Convert video data to BytesIO object
|
| 295 |
-
video_io = io.BytesIO(video_data)
|
| 296 |
-
|
| 297 |
-
# Return VideoFromFile object
|
| 298 |
-
return (VideoFromFile(video_io),)
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
# Register the node
|
| 302 |
-
NODE_CLASS_MAPPINGS = {
|
| 303 |
-
"VeoVideoGenerationNode": VeoVideoGenerationNode,
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 307 |
-
"VeoVideoGenerationNode": "Google Veo2 Video Generation",
|
| 308 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/redocly-dev.yaml
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
# This file is used to filter the Comfy Org OpenAPI spec for schemas related to API Nodes.
|
| 2 |
-
# This is used for development purposes to generate stubs for unreleased API endpoints.
|
| 3 |
-
apis:
|
| 4 |
-
filter:
|
| 5 |
-
root: openapi.yaml
|
| 6 |
-
decorators:
|
| 7 |
-
filter-in:
|
| 8 |
-
property: tags
|
| 9 |
-
value: ['API Nodes']
|
| 10 |
-
matchStrategy: all
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/redocly.yaml
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
# This file is used to filter the Comfy Org OpenAPI spec for schemas related to API Nodes.
|
| 2 |
-
|
| 3 |
-
apis:
|
| 4 |
-
filter:
|
| 5 |
-
root: openapi.yaml
|
| 6 |
-
decorators:
|
| 7 |
-
filter-in:
|
| 8 |
-
property: tags
|
| 9 |
-
value: ['API Nodes', 'Released']
|
| 10 |
-
matchStrategy: all
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comfy_api_nodes/util/__init__.py
DELETED
|
File without changes
|
comfy_api_nodes/util/validation_utils.py
DELETED
|
@@ -1,100 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
from typing import Optional
|
| 3 |
-
|
| 4 |
-
import torch
|
| 5 |
-
from comfy_api.input.video_types import VideoInput
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def get_image_dimensions(image: torch.Tensor) -> tuple[int, int]:
|
| 9 |
-
if len(image.shape) == 4:
|
| 10 |
-
return image.shape[1], image.shape[2]
|
| 11 |
-
elif len(image.shape) == 3:
|
| 12 |
-
return image.shape[0], image.shape[1]
|
| 13 |
-
else:
|
| 14 |
-
raise ValueError("Invalid image tensor shape.")
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def validate_image_dimensions(
|
| 18 |
-
image: torch.Tensor,
|
| 19 |
-
min_width: Optional[int] = None,
|
| 20 |
-
max_width: Optional[int] = None,
|
| 21 |
-
min_height: Optional[int] = None,
|
| 22 |
-
max_height: Optional[int] = None,
|
| 23 |
-
):
|
| 24 |
-
height, width = get_image_dimensions(image)
|
| 25 |
-
|
| 26 |
-
if min_width is not None and width < min_width:
|
| 27 |
-
raise ValueError(f"Image width must be at least {min_width}px, got {width}px")
|
| 28 |
-
if max_width is not None and width > max_width:
|
| 29 |
-
raise ValueError(f"Image width must be at most {max_width}px, got {width}px")
|
| 30 |
-
if min_height is not None and height < min_height:
|
| 31 |
-
raise ValueError(
|
| 32 |
-
f"Image height must be at least {min_height}px, got {height}px"
|
| 33 |
-
)
|
| 34 |
-
if max_height is not None and height > max_height:
|
| 35 |
-
raise ValueError(f"Image height must be at most {max_height}px, got {height}px")
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def validate_image_aspect_ratio(
|
| 39 |
-
image: torch.Tensor,
|
| 40 |
-
min_aspect_ratio: Optional[float] = None,
|
| 41 |
-
max_aspect_ratio: Optional[float] = None,
|
| 42 |
-
):
|
| 43 |
-
width, height = get_image_dimensions(image)
|
| 44 |
-
aspect_ratio = width / height
|
| 45 |
-
|
| 46 |
-
if min_aspect_ratio is not None and aspect_ratio < min_aspect_ratio:
|
| 47 |
-
raise ValueError(
|
| 48 |
-
f"Image aspect ratio must be at least {min_aspect_ratio}, got {aspect_ratio}"
|
| 49 |
-
)
|
| 50 |
-
if max_aspect_ratio is not None and aspect_ratio > max_aspect_ratio:
|
| 51 |
-
raise ValueError(
|
| 52 |
-
f"Image aspect ratio must be at most {max_aspect_ratio}, got {aspect_ratio}"
|
| 53 |
-
)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def validate_video_dimensions(
|
| 57 |
-
video: VideoInput,
|
| 58 |
-
min_width: Optional[int] = None,
|
| 59 |
-
max_width: Optional[int] = None,
|
| 60 |
-
min_height: Optional[int] = None,
|
| 61 |
-
max_height: Optional[int] = None,
|
| 62 |
-
):
|
| 63 |
-
try:
|
| 64 |
-
width, height = video.get_dimensions()
|
| 65 |
-
except Exception as e:
|
| 66 |
-
logging.error("Error getting dimensions of video: %s", e)
|
| 67 |
-
return
|
| 68 |
-
|
| 69 |
-
if min_width is not None and width < min_width:
|
| 70 |
-
raise ValueError(f"Video width must be at least {min_width}px, got {width}px")
|
| 71 |
-
if max_width is not None and width > max_width:
|
| 72 |
-
raise ValueError(f"Video width must be at most {max_width}px, got {width}px")
|
| 73 |
-
if min_height is not None and height < min_height:
|
| 74 |
-
raise ValueError(
|
| 75 |
-
f"Video height must be at least {min_height}px, got {height}px"
|
| 76 |
-
)
|
| 77 |
-
if max_height is not None and height > max_height:
|
| 78 |
-
raise ValueError(f"Video height must be at most {max_height}px, got {height}px")
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def validate_video_duration(
|
| 82 |
-
video: VideoInput,
|
| 83 |
-
min_duration: Optional[float] = None,
|
| 84 |
-
max_duration: Optional[float] = None,
|
| 85 |
-
):
|
| 86 |
-
try:
|
| 87 |
-
duration = video.get_duration()
|
| 88 |
-
except Exception as e:
|
| 89 |
-
logging.error("Error getting duration of video: %s", e)
|
| 90 |
-
return
|
| 91 |
-
|
| 92 |
-
epsilon = 0.0001
|
| 93 |
-
if min_duration is not None and min_duration - epsilon > duration:
|
| 94 |
-
raise ValueError(
|
| 95 |
-
f"Video duration must be at least {min_duration}s, got {duration}s"
|
| 96 |
-
)
|
| 97 |
-
if max_duration is not None and duration > max_duration + epsilon:
|
| 98 |
-
raise ValueError(
|
| 99 |
-
f"Video duration must be at most {max_duration}s, got {duration}s"
|
| 100 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|