diff --git a/comfy_api_nodes/apis/client.py b/comfy_api_nodes/apis/client.py index 1d9f40881..2a7a3adab 100644 --- a/comfy_api_nodes/apis/client.py +++ b/comfy_api_nodes/apis/client.py @@ -180,10 +180,14 @@ class ApiClient: data: Dict[str, Any], files: Dict[str, Any], headers: Optional[Dict[str, str]] = None, + multipart_parser = None, ) -> Dict[str, Any]: if headers and "Content-Type" in headers: del headers["Content-Type"] + if multipart_parser: + data = multipart_parser(data) + return { "data": data, "files": files, @@ -222,6 +226,7 @@ class ApiClient: files: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, content_type: str = "application/json", + multipart_parser: Callable = None, ) -> Dict[str, Any]: """ Make an HTTP request to the API @@ -261,7 +266,7 @@ class ApiClient: case "application/x-www-form-urlencoded": payload_args = self._create_urlencoded_form_data_args(data, request_headers) case "multipart/form-data": - payload_args = self._create_form_data_args(data, files, request_headers) + payload_args = self._create_form_data_args(data, files, request_headers, multipart_parser) case _: payload_args = self._create_json_payload_args(data, request_headers) @@ -400,6 +405,7 @@ class SynchronousOperation(Generic[T, R]): timeout: float = 604800.0, verify_ssl: bool = True, content_type: str = "application/json", + multipart_parser: Callable = None, ): self.endpoint = endpoint self.request = request @@ -411,6 +417,7 @@ class SynchronousOperation(Generic[T, R]): self.verify_ssl = verify_ssl self.files = files self.content_type = content_type + self.multipart_parser = multipart_parser def execute(self, client: Optional[ApiClient] = None) -> R: """Execute the API operation using the provided client or create one""" try: @@ -454,6 +461,7 @@ class SynchronousOperation(Generic[T, R]): params=self.endpoint.query_params, files=self.files, content_type=self.content_type, + multipart_parser=self.multipart_parser ) # Debug log for response diff --git a/comfy_api_nodes/nodes_recraft.py b/comfy_api_nodes/nodes_recraft.py index 5a60debc9..d8ac68797 100644 --- a/comfy_api_nodes/nodes_recraft.py +++ b/comfy_api_nodes/nodes_recraft.py @@ -66,6 +66,7 @@ def handle_recraft_file_request( files=files, content_type="multipart/form-data", auth_token=auth_token, + multipart_parser=recraft_multipart_parser, ) response: RecraftImageGenerationResponse = operation.execute() all_bytesio = [] @@ -78,6 +79,72 @@ def handle_recraft_file_request( return all_bytesio +def recraft_multipart_parser(data, parent_key=None, formatter: callable=None, converted_to_check: list[list]=None, is_list=False) -> dict: + """ + Formats data such that multipart/form-data will work with requests library + when both files and data are present. + + The OpenAI client that Recraft uses has a bizarre way of serializing lists: + + It does NOT keep track of indeces of each list, so for background_color, that must be serialized as: + 'background_color[rgb][]' = [0, 0, 255] + where the array is assigned to a key that has '[]' at the end, to signal it's an array. + + This has the consequence of nested lists having the exact same key, forcing arrays to merge; all colors inputs fall under the same key: + if 1 color -> 'controls[colors][][rgb][]' = [0, 0, 255] + if 2 colors -> 'controls[colors][][rgb][]' = [0, 0, 255, 255, 0, 0] + if 3 colors -> 'controls[colors][][rgb][]' = [0, 0, 255, 255, 0, 0, 0, 255, 0] + etc. + Whoever made this serialization up at OpenAI added the constraint that lists must be of uniform length on objects of same 'type'. + """ + # Modification of a function that handled a different type of multipart parsing, big ups: + # https://gist.github.com/kazqvaizer/4cebebe5db654a414132809f9f88067b + + def handle_converted_lists(data, parent_key, lists_to_check=tuple[list]): + # if list already exists exists, just extend list with data + for check_list in lists_to_check: + for conv_tuple in check_list: + if conv_tuple[0] == parent_key and type(conv_tuple[1]) is list: + conv_tuple[1].append(formatter(data)) + return True + return False + + if converted_to_check is None: + converted_to_check = [] + + + if formatter is None: + formatter = lambda v: v # Multipart representation of value + + if type(data) is not dict: + # if list already exists exists, just extend list with data + added = handle_converted_lists(data, parent_key, converted_to_check) + if added: + return {} + # otherwise if is_list, create new list with data + if is_list: + return {parent_key: [formatter(data)]} + # return new key with data + return {parent_key: formatter(data)} + + converted = [] + next_check = [converted] + next_check.extend(converted_to_check) + + for key, value in data.items(): + current_key = key if parent_key is None else f"{parent_key}[{key}]" + if type(value) is dict: + converted.extend(recraft_multipart_parser(value, current_key, formatter, next_check).items()) + elif type(value) is list: + for ind, list_value in enumerate(value): + iter_key = f"{current_key}[]" + converted.extend(recraft_multipart_parser(list_value, iter_key, formatter, next_check, is_list=True).items()) + else: + converted.append((current_key, formatter(value))) + + return dict(converted) + + class SVG: """ Stores SVG representations via a list of BytesIO objects.