Set Content-Type header when uploading files (#36)

This commit is contained in:
Christian Byrne 2025-04-29 16:15:50 -07:00 committed by Robin Huang
parent a55f29662d
commit 36a201cec7
3 changed files with 174 additions and 73 deletions

View File

@ -738,22 +738,28 @@ class ApiClient:
def upload_file( def upload_file(
upload_url: str, upload_url: str,
file: io.BytesIO | str, file: io.BytesIO | str,
mime_type: str | None = None,
): ):
"""Upload a file to the API. Make sure the file has a filename equal to what the url expects. """Upload a file to the API. Make sure the file has a filename equal to what the url expects.
Args: Args:
upload_url: The URL to upload to upload_url: The URL to upload to
file: Either a file path string, BytesIO object, or tuple of (file_path, filename) file: Either a file path string, BytesIO object, or tuple of (file_path, filename)
mime_type: The mime type of the file mime_type: Optional mime type to set for the upload
""" """
headers = {}
if mime_type:
headers["Content-Type"] = mime_type
if isinstance(file, io.BytesIO): if isinstance(file, io.BytesIO):
file.seek(0) # Ensure we're at the start of the file file.seek(0) # Ensure we're at the start of the file
data = file.read() data = file.read()
return requests.put(upload_url, data=data) return requests.put(upload_url, data=data, headers=headers)
elif isinstance(file, str): elif isinstance(file, str):
with open(file, "rb") as f: with open(file, "rb") as f:
data = f.read() data = f.read()
return requests.put(upload_url, data=data) return requests.put(upload_url, data=data, headers=headers)
class ApiEndpoint(Generic[T, R]): class ApiEndpoint(Generic[T, R]):
"""Defines an API endpoint with its request and response types""" """Defines an API endpoint with its request and response types"""

View File

@ -51,7 +51,16 @@ from comfy_api_nodes.apis.recraft_api import (
RecraftIO, RecraftIO,
get_v3_substyles, get_v3_substyles,
) )
from comfy_api_nodes.apis.client import ApiClient, ApiEndpoint, HttpMethod, SynchronousOperation, PollingOperation, EmptyRequest, UploadRequest, UploadResponse from comfy_api_nodes.apis.client import (
ApiClient,
ApiEndpoint,
HttpMethod,
SynchronousOperation,
PollingOperation,
EmptyRequest,
UploadRequest,
UploadResponse,
)
import numpy as np import numpy as np
from PIL import Image from PIL import Image
@ -65,9 +74,10 @@ import uuid
import folder_paths import folder_paths
from io import BytesIO from io import BytesIO
def downscale_input(image, total_pixels=1536*1024):
samples = image.movedim(-1,1) def downscale_input(image, total_pixels=1536 * 1024):
#downscaling input images to roughly the same size as the outputs samples = image.movedim(-1, 1)
# downscaling input images to roughly the same size as the outputs
total = int(total_pixels) total = int(total_pixels)
scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2])) scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
if scale_by >= 1: if scale_by >= 1:
@ -76,9 +86,10 @@ def downscale_input(image, total_pixels=1536*1024):
height = round(samples.shape[2] * scale_by) height = round(samples.shape[2] * scale_by)
s = common_upscale(samples, width, height, "lanczos", "disabled") s = common_upscale(samples, width, height, "lanczos", "disabled")
s = s.movedim(1,-1) s = s.movedim(1, -1)
return s return s
def validate_and_cast_response(response): def validate_and_cast_response(response):
# validate raw JSON response # validate raw JSON response
data = response.data data = response.data
@ -117,29 +128,46 @@ def validate_and_cast_response(response):
return torch.stack(image_tensors, dim=0) 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):
def validate_aspect_ratio(
aspect_ratio: str,
minimum_ratio: float,
maximum_ratio: float,
minimum_ratio_str: str,
maximum_ratio_str: str,
):
# get ratio values # get ratio values
numbers = aspect_ratio.split(':') numbers = aspect_ratio.split(":")
if len(numbers) != 2: if len(numbers) != 2:
raise Exception(f"Aspect ratio must be in the format X:Y, such as 16:9, but was {aspect_ratio}.") raise Exception(
f"Aspect ratio must be in the format X:Y, such as 16:9, but was {aspect_ratio}."
)
try: try:
numerator = int(numbers[0]) numerator = int(numbers[0])
denominator = int(numbers[1]) denominator = int(numbers[1])
except ValueError: except ValueError:
raise Exception(f"Aspect ratio must contain numbers separated by ':', such as 16:9, but was {aspect_ratio}.") raise Exception(
calculated_ratio = numerator/denominator f"Aspect ratio must contain numbers separated by ':', such as 16:9, but was {aspect_ratio}."
)
calculated_ratio = numerator / denominator
# if not close to minimum and maximum, check bounds # if not close to minimum and maximum, check bounds
if not math.isclose(calculated_ratio, minimum_ratio) or not math.isclose(calculated_ratio, maximum_ratio): if not math.isclose(calculated_ratio, minimum_ratio) or not math.isclose(
calculated_ratio, maximum_ratio
):
if calculated_ratio < minimum_ratio: if calculated_ratio < minimum_ratio:
raise Exception(f"Aspect ratio cannot reduce to any less than {minimum_ratio_str} ({minimum_ratio}), but was {aspect_ratio} ({calculated_ratio}).") raise Exception(
f"Aspect ratio cannot reduce to any less than {minimum_ratio_str} ({minimum_ratio}), but was {aspect_ratio} ({calculated_ratio})."
)
elif calculated_ratio > maximum_ratio: elif calculated_ratio > maximum_ratio:
raise Exception(f"Aspect ratio cannot reduce to any greater than {maximum_ratio_str} ({maximum_ratio}), but was {aspect_ratio} ({calculated_ratio}).") raise Exception(
f"Aspect ratio cannot reduce to any greater than {maximum_ratio_str} ({maximum_ratio}), but was {aspect_ratio} ({calculated_ratio})."
)
return aspect_ratio return aspect_ratio
def mimetype_to_extension(mime_type: str) -> str: def mimetype_to_extension(mime_type: str) -> str:
"""Converts a MIME type to a file extension.""" """Converts a MIME type to a file extension."""
return mime_type.split('/')[-1].lower() return mime_type.split("/")[-1].lower()
def download_url_to_bytesio(url: str, timeout: int = None) -> BytesIO: def download_url_to_bytesio(url: str, timeout: int = None) -> BytesIO:
@ -153,7 +181,7 @@ def download_url_to_bytesio(url: str, timeout: int = None) -> BytesIO:
BytesIO object containing the downloaded content. BytesIO object containing the downloaded content.
""" """
response = requests.get(url, stream=True, timeout=timeout) response = requests.get(url, stream=True, timeout=timeout)
response.raise_for_status() # Raises HTTPError for bad responses (4XX or 5XX) response.raise_for_status() # Raises HTTPError for bad responses (4XX or 5XX)
return BytesIO(response.content) return BytesIO(response.content)
@ -178,35 +206,42 @@ def bytesio_to_image_tensor(image_bytesio: BytesIO, mode: str = "RGBA") -> torch
def process_image_response(response: requests.Response): def process_image_response(response: requests.Response):
'''Uses content from a Response object and converts it to a torch.Tensor''' """Uses content from a Response object and converts it to a torch.Tensor"""
return bytesio_to_image_tensor(BytesIO(response.content)) return bytesio_to_image_tensor(BytesIO(response.content))
def _tensor_to_pil(image: torch.Tensor, total_pixels: int = 2048*2048) -> Image.Image: def _tensor_to_pil(image: torch.Tensor, total_pixels: int = 2048 * 2048) -> Image.Image:
"""Converts a single torch.Tensor image [H, W, C] to a PIL Image, optionally downscaling.""" """Converts a single torch.Tensor image [H, W, C] to a PIL Image, optionally downscaling."""
if len(image.shape) > 3: if len(image.shape) > 3:
image = image[0] image = image[0]
# TODO: remove alpha if not allowed and present # TODO: remove alpha if not allowed and present
input_tensor = image.cpu() input_tensor = image.cpu()
input_tensor = downscale_input(input_tensor.unsqueeze(0), total_pixels=total_pixels).squeeze() input_tensor = downscale_input(
input_tensor.unsqueeze(0), total_pixels=total_pixels
).squeeze()
image_np = (input_tensor.numpy() * 255).astype(np.uint8) image_np = (input_tensor.numpy() * 255).astype(np.uint8)
img = Image.fromarray(image_np) img = Image.fromarray(image_np)
return img return img
def _pil_to_bytesio(img: Image.Image, mime_type: str = 'image/png') -> BytesIO: def _pil_to_bytesio(img: Image.Image, mime_type: str = "image/png") -> BytesIO:
"""Converts a PIL Image to a BytesIO object.""" """Converts a PIL Image to a BytesIO object."""
img_byte_arr = io.BytesIO() img_byte_arr = io.BytesIO()
# Derive PIL format from MIME type (e.g., 'image/png' -> 'PNG') # Derive PIL format from MIME type (e.g., 'image/png' -> 'PNG')
pil_format = mime_type.split('/')[-1].upper() pil_format = mime_type.split("/")[-1].upper()
if pil_format == 'JPG': if pil_format == "JPG":
pil_format = 'JPEG' pil_format = "JPEG"
img.save(img_byte_arr, format=pil_format) img.save(img_byte_arr, format=pil_format)
img_byte_arr.seek(0) img_byte_arr.seek(0)
return img_byte_arr return img_byte_arr
def tensor_to_bytesio(image: torch.Tensor, name: Optional[str] = None, total_pixels: int = 2048*2048, mime_type: str = 'image/png') -> BytesIO: def tensor_to_bytesio(
image: torch.Tensor,
name: Optional[str] = None,
total_pixels: int = 2048 * 2048,
mime_type: str = "image/png",
) -> BytesIO:
"""Converts a torch.Tensor image to a named BytesIO object. """Converts a torch.Tensor image to a named BytesIO object.
Args: Args:
@ -220,11 +255,17 @@ def tensor_to_bytesio(image: torch.Tensor, name: Optional[str] = None, total_pix
""" """
pil_image = _tensor_to_pil(image, total_pixels=total_pixels) pil_image = _tensor_to_pil(image, total_pixels=total_pixels)
img_binary = _pil_to_bytesio(pil_image, mime_type=mime_type) img_binary = _pil_to_bytesio(pil_image, mime_type=mime_type)
img_binary.name = f"{name if name else uuid.uuid4()}.{mimetype_to_extension(mime_type)}" img_binary.name = (
f"{name if name else uuid.uuid4()}.{mimetype_to_extension(mime_type)}"
)
return img_binary return img_binary
def tensor_to_base64_string(image_tensor: torch.Tensor, total_pixels: int = 2048*2048, mime_type: str = 'image/png') -> str: def tensor_to_base64_string(
image_tensor: torch.Tensor,
total_pixels: int = 2048 * 2048,
mime_type: str = "image/png",
) -> str:
"""Convert [B, H, W, C] or [H, W, C] tensor to a base64 string. """Convert [B, H, W, C] or [H, W, C] tensor to a base64 string.
Args: Args:
@ -243,7 +284,11 @@ def tensor_to_base64_string(image_tensor: torch.Tensor, total_pixels: int = 2048
return base64_encoded_string return base64_encoded_string
def tensor_to_data_uri(image_tensor: torch.Tensor, total_pixels: int = 2048 * 2048, mime_type: str = 'image/png') -> str: def tensor_to_data_uri(
image_tensor: torch.Tensor,
total_pixels: int = 2048 * 2048,
mime_type: str = "image/png",
) -> str:
"""Converts a tensor image to a Data URI string. """Converts a tensor image to a Data URI string.
Args: Args:
@ -258,7 +303,9 @@ def tensor_to_data_uri(image_tensor: torch.Tensor, total_pixels: int = 2048 * 20
return f"data:{mime_type};base64,{base64_string}" return f"data:{mime_type};base64,{base64_string}"
def upload_images_to_comfyapi(image: torch.Tensor, max_images=8, auth_token=None) -> list[str]: def upload_images_to_comfyapi(
image: torch.Tensor, max_images=8, auth_token=None, mime_type: str = "image/png"
) -> list[str]:
# if batch, try to upload each file if max_images is greater than 0 # if batch, try to upload each file if max_images is greater than 0
idx_image = 0 idx_image = 0
download_urls: list[str] = [] download_urls: list[str] = []
@ -271,7 +318,7 @@ def upload_images_to_comfyapi(image: torch.Tensor, max_images=8, auth_token=None
if len(image.shape) > 3: if len(image.shape) > 3:
curr_image = image[idx_image] curr_image = image[idx_image]
# get BytesIO version of image # get BytesIO version of image
img_binary = tensor_to_bytesio(curr_image) img_binary = tensor_to_bytesio(curr_image, mime_type=mime_type)
# first, request upload/download urls from comfy API # first, request upload/download urls from comfy API
operation = SynchronousOperation( operation = SynchronousOperation(
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
@ -280,13 +327,13 @@ def upload_images_to_comfyapi(image: torch.Tensor, max_images=8, auth_token=None
request_model=UploadRequest, request_model=UploadRequest,
response_model=UploadResponse, response_model=UploadResponse,
), ),
request=UploadRequest(filename=img_binary.name), request=UploadRequest(filename=img_binary.name, mime_type=mime_type),
auth_token=auth_token, auth_token=auth_token,
) )
response = operation.execute() response = operation.execute()
upload_response = ApiClient.upload_file( upload_response = ApiClient.upload_file(
response.upload_url, img_binary response.upload_url, img_binary, mime_type=mime_type
) )
# verify success # verify success
try: try:
@ -1174,6 +1221,7 @@ class FluxProUltraImageNode(ComfyNodeABC):
img.save(img_byte_arr, format="PNG") img.save(img_byte_arr, format="PNG")
return base64.b64encode(img_byte_arr.getvalue()).decode() return base64.b64encode(img_byte_arr.getvalue()).decode()
class LumaReferenceNode(ComfyNodeABC): class LumaReferenceNode(ComfyNodeABC):
""" """
Holds an image and weight for use with Luma Generate Image node. Holds an image and weight for use with Luma Generate Image node.
@ -1224,6 +1272,7 @@ class LumaConceptsNode(ComfyNodeABC):
""" """
Holds one or more Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes. Holds one or more Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.
""" """
RETURN_TYPES = (LumaIO.LUMA_CONCEPTS,) RETURN_TYPES = (LumaIO.LUMA_CONCEPTS,)
RETURN_NAMES = ("luma_concepts",) RETURN_NAMES = ("luma_concepts",)
DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
@ -1234,24 +1283,35 @@ class LumaConceptsNode(ComfyNodeABC):
def INPUT_TYPES(s): def INPUT_TYPES(s):
return { return {
"required": { "required": {
"concept1": (get_luma_concepts(include_none=True), ), "concept1": (get_luma_concepts(include_none=True),),
"concept2": (get_luma_concepts(include_none=True), ), "concept2": (get_luma_concepts(include_none=True),),
"concept3": (get_luma_concepts(include_none=True), ), "concept3": (get_luma_concepts(include_none=True),),
"concept4": (get_luma_concepts(include_none=True), ), "concept4": (get_luma_concepts(include_none=True),),
}, },
"optional": { "optional": {
"luma_concepts": (LumaIO.LUMA_CONCEPTS, { "luma_concepts": (
"tooltip": "Optional Camera Concepts to add to the ones chosen here." LumaIO.LUMA_CONCEPTS,
}), {
} "tooltip": "Optional Camera Concepts to add to the ones chosen here."
},
),
},
} }
def create_concepts(self, concept1: str, concept2: str, concept3: str, concept4: str, luma_concepts: LumaConceptChain=None): def create_concepts(
self,
concept1: str,
concept2: str,
concept3: str,
concept4: str,
luma_concepts: LumaConceptChain = None,
):
chain = LumaConceptChain(str_list=[concept1, concept2, concept3, concept4]) chain = LumaConceptChain(str_list=[concept1, concept2, concept3, concept4])
if luma_concepts is not None: if luma_concepts is not None:
chain = luma_concepts.clone_and_merge(chain) chain = luma_concepts.clone_and_merge(chain)
return (chain,) return (chain,)
class LumaImageGenerationNode(ComfyNodeABC): class LumaImageGenerationNode(ComfyNodeABC):
""" """
Generates images synchronously based on prompt and aspect ratio. Generates images synchronously based on prompt and aspect ratio.
@ -1421,6 +1481,7 @@ class LumaImageGenerationNode(ComfyNodeABC):
) )
return self._convert_luma_refs(chain, max_refs=1, auth_token=auth_token) return self._convert_luma_refs(chain, max_refs=1, auth_token=auth_token)
class LumaImageModifyNode(ComfyNodeABC): class LumaImageModifyNode(ComfyNodeABC):
""" """
Modifies images synchronously based on prompt and aspect ratio. Modifies images synchronously based on prompt and aspect ratio.
@ -1525,6 +1586,7 @@ class LumaImageModifyNode(ComfyNodeABC):
img = process_image_response(img_response) img = process_image_response(img_response)
return (img,) return (img,)
class LumaTextToVideoGenerationNode(ComfyNodeABC): class LumaTextToVideoGenerationNode(ComfyNodeABC):
""" """
Generates videos synchronously based on prompt and output_size. Generates videos synchronously based on prompt and output_size.
@ -1566,29 +1628,49 @@ class LumaTextToVideoGenerationNode(ComfyNodeABC):
}, },
), ),
"duration": ([dur.value for dur in LumaVideoModelOutputDuration],), "duration": ([dur.value for dur in LumaVideoModelOutputDuration],),
"loop": (IO.BOOLEAN, { "loop": (
"default": False, IO.BOOLEAN,
}), {
"seed": (IO.INT, { "default": False,
"default": 0, },
"min": 0, ),
"max": 0xFFFFFFFFFFFFFFFF, "seed": (
"control_after_generate": True, IO.INT,
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.", {
}), "default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"control_after_generate": True,
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
},
),
}, },
"optional": { "optional": {
"luma_concepts": (LumaIO.LUMA_CONCEPTS, { "luma_concepts": (
"tooltip": "Optional Camera Concepts to dictate camera motion via the Luma Concepts node." LumaIO.LUMA_CONCEPTS,
}), {
"tooltip": "Optional Camera Concepts to dictate camera motion via the Luma Concepts node."
},
),
}, },
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
}, },
} }
def api_call(self, prompt: str, model: str, aspect_ratio: str, resolution: str, duration: str, loop: bool, seed, def api_call(
luma_concepts: LumaConceptChain=None, auth_token=None, **kwargs): self,
prompt: str,
model: str,
aspect_ratio: str,
resolution: str,
duration: str,
loop: bool,
seed,
luma_concepts: LumaConceptChain = None,
auth_token=None,
**kwargs,
):
operation = SynchronousOperation( operation = SynchronousOperation(
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
path="/proxy/luma/generations", path="/proxy/luma/generations",
@ -1603,7 +1685,7 @@ class LumaTextToVideoGenerationNode(ComfyNodeABC):
aspect_ratio=aspect_ratio, aspect_ratio=aspect_ratio,
duration=duration, duration=duration,
loop=loop, loop=loop,
concepts=luma_concepts.create_api_model() if luma_concepts else None concepts=luma_concepts.create_api_model() if luma_concepts else None,
), ),
auth_token=auth_token, auth_token=auth_token,
) )
@ -1683,24 +1765,37 @@ class LumaImageToVideoGenerationNode(ComfyNodeABC):
), ),
}, },
"optional": { "optional": {
"first_image": (IO.IMAGE, { "first_image": (
"tooltip": "First frame of generated video." IO.IMAGE,
}), {"tooltip": "First frame of generated video."},
"last_image": (IO.IMAGE, { ),
"tooltip": "Last frame of generated video." "last_image": (IO.IMAGE, {"tooltip": "Last frame of generated video."}),
}), "luma_concepts": (
"luma_concepts": (LumaIO.LUMA_CONCEPTS, { LumaIO.LUMA_CONCEPTS,
"tooltip": "Optional Camera Concepts to dictate camera motion via the Luma Concepts node." {
}), "tooltip": "Optional Camera Concepts to dictate camera motion via the Luma Concepts node."
},
),
}, },
"hidden": { "hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG", "auth_token": "AUTH_TOKEN_COMFY_ORG",
}, },
} }
def api_call(self, prompt: str, model: str, resolution: str, duration: str, loop: bool, seed, def api_call(
first_image: torch.Tensor=None, last_image: torch.Tensor=None, luma_concepts: LumaConceptChain=None, self,
auth_token=None, **kwargs): prompt: str,
model: str,
resolution: str,
duration: str,
loop: bool,
seed,
first_image: torch.Tensor = None,
last_image: torch.Tensor = None,
luma_concepts: LumaConceptChain = None,
auth_token=None,
**kwargs,
):
if first_image is None and last_image is None: if first_image is None and last_image is None:
raise Exception( raise Exception(
"At least one of first_image and last_image requires an input." "At least one of first_image and last_image requires an input."
@ -1717,12 +1812,12 @@ class LumaImageToVideoGenerationNode(ComfyNodeABC):
request=LumaGenerationRequest( request=LumaGenerationRequest(
prompt=prompt, prompt=prompt,
model=model, model=model,
aspect_ratio=LumaAspectRatio.ratio_16_9, # ignored, but still needed by the API for some reason aspect_ratio=LumaAspectRatio.ratio_16_9, # ignored, but still needed by the API for some reason
resolution=resolution, resolution=resolution,
duration=duration, duration=duration,
loop=loop, loop=loop,
keyframes=keyframes, keyframes=keyframes,
concepts=luma_concepts.create_api_model() if luma_concepts else None concepts=luma_concepts.create_api_model() if luma_concepts else None,
), ),
auth_token=auth_token, auth_token=auth_token,
) )

View File

@ -193,7 +193,7 @@ class RunwayImageToVideoNode(ComfyNodeABC):
prompt_images_tensor = torch.cat(prompt_images_tensors, dim=0) prompt_images_tensor = torch.cat(prompt_images_tensors, dim=0)
download_urls = upload_images_to_comfyapi( download_urls = upload_images_to_comfyapi(
prompt_images_tensor, max_images=2, auth_token=auth_token prompt_images_tensor, max_images=2, auth_token=auth_token, mime_type="image/png"
) )
# Create a list of detailed image objects # Create a list of detailed image objects