| | from __future__ import annotations |
| | import aiohttp |
| | import mimetypes |
| | from typing import Optional, Union |
| | from comfy.utils import common_upscale |
| | from comfy_api_nodes.apis.client import ( |
| | ApiClient, |
| | ApiEndpoint, |
| | HttpMethod, |
| | SynchronousOperation, |
| | UploadRequest, |
| | UploadResponse, |
| | ) |
| | from server import PromptServer |
| | from comfy.cli_args import args |
| |
|
| | import numpy as np |
| | from PIL import Image |
| | import torch |
| | import math |
| | import base64 |
| | from .util import tensor_to_bytesio, bytesio_to_image_tensor |
| | from io import BytesIO |
| |
|
| |
|
| | async def validate_and_cast_response( |
| | response, timeout: int = None, node_id: Union[str, None] = None |
| | ) -> torch.Tensor: |
| | """Validates and casts a response to a torch.Tensor. |
| | |
| | Args: |
| | response: The response to validate and cast. |
| | timeout: Request timeout in seconds. Defaults to None (no timeout). |
| | |
| | Returns: |
| | A torch.Tensor representing the image (1, H, W, C). |
| | |
| | Raises: |
| | ValueError: If the response is not valid. |
| | """ |
| | |
| | data = response.data |
| | if not data or len(data) == 0: |
| | raise ValueError("No images returned from API endpoint") |
| |
|
| | |
| | image_tensors: list[torch.Tensor] = [] |
| |
|
| | |
| | async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session: |
| | for img_data in data: |
| | img_bytes: bytes |
| | if img_data.b64_json: |
| | img_bytes = base64.b64decode(img_data.b64_json) |
| | elif img_data.url: |
| | if node_id: |
| | PromptServer.instance.send_progress_text(f"Result URL: {img_data.url}", node_id) |
| | async with session.get(img_data.url) as resp: |
| | if resp.status != 200: |
| | raise ValueError("Failed to download generated image") |
| | img_bytes = await resp.read() |
| | else: |
| | raise ValueError("Invalid image payload – neither URL nor base64 data present.") |
| |
|
| | pil_img = Image.open(BytesIO(img_bytes)).convert("RGBA") |
| | arr = np.asarray(pil_img).astype(np.float32) / 255.0 |
| | image_tensors.append(torch.from_numpy(arr)) |
| |
|
| | return torch.stack(image_tensors, dim=0) |
| |
|
| |
|
| | def validate_aspect_ratio( |
| | aspect_ratio: str, |
| | minimum_ratio: float, |
| | maximum_ratio: float, |
| | minimum_ratio_str: str, |
| | maximum_ratio_str: str, |
| | ) -> float: |
| | """Validates and casts an aspect ratio string to a float. |
| | |
| | Args: |
| | aspect_ratio: The aspect ratio string to validate. |
| | minimum_ratio: The minimum aspect ratio. |
| | maximum_ratio: The maximum aspect ratio. |
| | minimum_ratio_str: The minimum aspect ratio string. |
| | maximum_ratio_str: The maximum aspect ratio string. |
| | |
| | Returns: |
| | The validated and cast aspect ratio. |
| | |
| | Raises: |
| | Exception: If the aspect ratio is not valid. |
| | """ |
| | |
| | numbers = aspect_ratio.split(":") |
| | if len(numbers) != 2: |
| | raise TypeError( |
| | f"Aspect ratio must be in the format X:Y, such as 16:9, but was {aspect_ratio}." |
| | ) |
| | try: |
| | numerator = int(numbers[0]) |
| | denominator = int(numbers[1]) |
| | except ValueError as exc: |
| | raise TypeError( |
| | f"Aspect ratio must contain numbers separated by ':', such as 16:9, but was {aspect_ratio}." |
| | ) from exc |
| | calculated_ratio = numerator / denominator |
| | |
| | if not math.isclose(calculated_ratio, minimum_ratio) or not math.isclose( |
| | calculated_ratio, maximum_ratio |
| | ): |
| | if calculated_ratio < minimum_ratio: |
| | raise TypeError( |
| | f"Aspect ratio cannot reduce to any less than {minimum_ratio_str} ({minimum_ratio}), but was {aspect_ratio} ({calculated_ratio})." |
| | ) |
| | if calculated_ratio > maximum_ratio: |
| | raise TypeError( |
| | f"Aspect ratio cannot reduce to any greater than {maximum_ratio_str} ({maximum_ratio}), but was {aspect_ratio} ({calculated_ratio})." |
| | ) |
| | return aspect_ratio |
| |
|
| |
|
| | async def download_url_to_bytesio( |
| | url: str, timeout: int = None, auth_kwargs: Optional[dict[str, str]] = None |
| | ) -> BytesIO: |
| | """Downloads content from a URL using requests and returns it as BytesIO. |
| | |
| | Args: |
| | url: The URL to download. |
| | timeout: Request timeout in seconds. Defaults to None (no timeout). |
| | |
| | Returns: |
| | BytesIO object containing the downloaded content. |
| | """ |
| | headers = {} |
| | if url.startswith("/proxy/"): |
| | url = str(args.comfy_api_base).rstrip("/") + url |
| | auth_token = auth_kwargs.get("auth_token") |
| | comfy_api_key = auth_kwargs.get("comfy_api_key") |
| | if auth_token: |
| | headers["Authorization"] = f"Bearer {auth_token}" |
| | elif comfy_api_key: |
| | headers["X-API-KEY"] = comfy_api_key |
| | timeout_cfg = aiohttp.ClientTimeout(total=timeout) if timeout else None |
| | async with aiohttp.ClientSession(timeout=timeout_cfg) as session: |
| | async with session.get(url, headers=headers) as resp: |
| | resp.raise_for_status() |
| | return BytesIO(await resp.read()) |
| |
|
| |
|
| | def process_image_response(response_content: bytes | str) -> torch.Tensor: |
| | """Uses content from a Response object and converts it to a torch.Tensor""" |
| | return bytesio_to_image_tensor(BytesIO(response_content)) |
| |
|
| |
|
| | def text_filepath_to_base64_string(filepath: str) -> str: |
| | """Converts a text file to a base64 string.""" |
| | with open(filepath, "rb") as f: |
| | file_content = f.read() |
| | return base64.b64encode(file_content).decode("utf-8") |
| |
|
| |
|
| | def text_filepath_to_data_uri(filepath: str) -> str: |
| | """Converts a text file to a data URI.""" |
| | base64_string = text_filepath_to_base64_string(filepath) |
| | mime_type, _ = mimetypes.guess_type(filepath) |
| | if mime_type is None: |
| | mime_type = "application/octet-stream" |
| | return f"data:{mime_type};base64,{base64_string}" |
| |
|
| |
|
| | async def upload_file_to_comfyapi( |
| | file_bytes_io: BytesIO, |
| | filename: str, |
| | upload_mime_type: Optional[str], |
| | auth_kwargs: Optional[dict[str, str]] = None, |
| | ) -> str: |
| | """ |
| | Uploads a single file to ComfyUI API and returns its download URL. |
| | |
| | Args: |
| | file_bytes_io: BytesIO object containing the file data. |
| | filename: The filename of the file. |
| | upload_mime_type: MIME type of the file. |
| | auth_kwargs: Optional authentication token(s). |
| | |
| | Returns: |
| | The download URL for the uploaded file. |
| | """ |
| | if upload_mime_type is None: |
| | request_object = UploadRequest(file_name=filename) |
| | else: |
| | request_object = UploadRequest(file_name=filename, content_type=upload_mime_type) |
| | operation = SynchronousOperation( |
| | endpoint=ApiEndpoint( |
| | path="/customers/storage", |
| | method=HttpMethod.POST, |
| | request_model=UploadRequest, |
| | response_model=UploadResponse, |
| | ), |
| | request=request_object, |
| | auth_kwargs=auth_kwargs, |
| | ) |
| |
|
| | response: UploadResponse = await operation.execute() |
| | await ApiClient.upload_file(response.upload_url, file_bytes_io, content_type=upload_mime_type) |
| | return response.download_url |
| |
|
| |
|
| | async def upload_images_to_comfyapi( |
| | image: torch.Tensor, |
| | max_images=8, |
| | auth_kwargs: Optional[dict[str, str]] = None, |
| | mime_type: Optional[str] = None, |
| | ) -> list[str]: |
| | """ |
| | Uploads images to ComfyUI API and returns download URLs. |
| | To upload multiple images, stack them in the batch dimension first. |
| | |
| | Args: |
| | image: Input torch.Tensor image. |
| | max_images: Maximum number of images to upload. |
| | auth_kwargs: Optional authentication token(s). |
| | mime_type: Optional MIME type for the image. |
| | """ |
| | |
| | download_urls: list[str] = [] |
| | is_batch = len(image.shape) > 3 |
| | batch_len = image.shape[0] if is_batch else 1 |
| |
|
| | for idx in range(min(batch_len, max_images)): |
| | tensor = image[idx] if is_batch else image |
| | img_io = tensor_to_bytesio(tensor, mime_type=mime_type) |
| | url = await upload_file_to_comfyapi(img_io, img_io.name, mime_type, auth_kwargs) |
| | download_urls.append(url) |
| | return download_urls |
| |
|
| |
|
| | def resize_mask_to_image( |
| | mask: torch.Tensor, |
| | image: torch.Tensor, |
| | upscale_method="nearest-exact", |
| | crop="disabled", |
| | allow_gradient=True, |
| | add_channel_dim=False, |
| | ): |
| | """ |
| | Resize mask to be the same dimensions as an image, while maintaining proper format for API calls. |
| | """ |
| | _, H, W, _ = image.shape |
| | mask = mask.unsqueeze(-1) |
| | mask = mask.movedim(-1, 1) |
| | mask = common_upscale( |
| | mask, width=W, height=H, upscale_method=upscale_method, crop=crop |
| | ) |
| | mask = mask.movedim(1, -1) |
| | if not add_channel_dim: |
| | mask = mask.squeeze(-1) |
| | if not allow_gradient: |
| | mask = (mask > 0.5).float() |
| | return mask |
| |
|