diff --git a/__init__.py b/__init__.py index 7ffadca..5138b36 100644 --- a/__init__.py +++ b/__init__.py @@ -7,6 +7,7 @@ from .nodes4tuzi import ( LoadVideoFromUrlComfyIO, ) from .nodes4hypr import HyprLab_Image_API_Node +from .nodes4doubao import seedance_api_node, seedream_api_node # Exporting the node classes for ComfyUI to discover NODE_CLASS_MAPPINGS = { @@ -33,6 +34,8 @@ NODE_CLASS_MAPPINGS = { "load_video_from_url": LoadVideoFromUrlVHS, "load_video_from_url_comfy_core": LoadVideoFromUrlComfyIO, "hyprlab_image_api_node": HyprLab_Image_API_Node, + "doubao_seedance_api_node": seedance_api_node, + "doubao_seedream_api_node": seedream_api_node, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -59,4 +62,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "BillBum_Modified_GPTImage1_API_Node": "Custom GPTImage1 API Node", "BillBum_Modified_Flux_API_with_imgInput": "Custom Flux API Node", "hyprlab_image_api_node": "HyprLab ImageGen API Node", + "doubao_seedance_api_node": "Doubao Seedance VideoGen API Node", + "doubao_seedream_api_node": "Doubao Seedream ImageGen API Node", } \ No newline at end of file diff --git a/nodes4doubao.py b/nodes4doubao.py new file mode 100644 index 0000000..c69dec1 --- /dev/null +++ b/nodes4doubao.py @@ -0,0 +1,327 @@ +import io +from PIL import Image +import numpy as np +import torch +import requests +import base64 +import time +import json +import tenacity +import math +from comfy.utils import common_upscale + + +## DataType Conversion Functions +def tensor2pil(image): + return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)) + +def pil2tensor(image): + return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0) + +def tensor2ndarray(image): + return np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8) + +def ndarray2tensor(image): + return torch.from_numpy(image.astype(np.float32) / 255.0).unsqueeze(0) + + +## Node Classes +class seedance_api_node: + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "model": ("STRING", {"default": "doubao-seedance-1-5-pro-251215"}), + "prompt": ("STRING", {"forceInput": True}), + "seed": ("INT", {"default": -1, "min": -1, "max": 0xffffffff}), + "api_url": ("STRING", {"default": "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks"}), + "api_key": ("STRING", {"default": "Input_your_API_key_here..."}), + "resolution": (["480p", "720p", "1080p"], {"default":"480p"}), + "ratio": (["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"], {"default":"adaptive"}), + "duration": ("INT", {"default":5, "min":1, "max":12, "step":1}), + "camerafixed": (["true", "false"], {"default":"false"}), + "watermark": (["true", "false"], {"default":"false"}) + }, + "optional": { + "first_frame": ("IMAGE",), + "last_frame": ("IMAGE",), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("response_str",) + FUNCTION = "create_seedance_task" + CATEGORY = "BillBum/API Nodes" + + def _poll_task_status(self, task_id, api_url, api_key, interval=1, max_attempts=500): + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + url = f"{api_url}/{task_id}" + + for attempt in range(max_attempts): + try: + response = requests.get(url, headers=headers) + if response.status_code == 200: + task_data = response.json() + status = task_data.get("status") + response_text = json.dumps(task_data, indent=2, ensure_ascii=False) + if status in ["succeeded", "failed", "cancelled"]: + return response_text + else: + time.sleep(interval) + else: + return f"Failed to fetch task status. HTTP Status Code: {response.status_code}\nResponse: {response.text}" + except Exception as e: + return f"An exception occurred: {str(e)}" + return "Polling timed out." + + def _to_base64_url(self, image_tensor): + pil_image = tensor2pil(image_tensor) + buffered = io.BytesIO() + pil_image.save(buffered, format="PNG") + img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") + return f"data:image/png;base64,{img_str}" + + def create_seedance_task(self, model, prompt, seed, api_url, api_key, resolution, ratio, duration, camerafixed, watermark, first_frame=None, last_frame=None): + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + + content = [{"type": "text", "text": prompt}] + + if first_frame is not None: + fsf_b64url = self._to_base64_url(first_frame) + content.append({ + "type": "image_url", + "image_url": {"url": fsf_b64url}, + "role": "first_frame" + }) + + if last_frame is not None: + lsf_b64url = self._to_base64_url(last_frame) + content.append({ + "type": "image_url", + "image_url": {"url": lsf_b64url}, + "role": "last_frame" + }) + + data = { + "model": model, + "content": content, + "ratio": ratio, + "resolution": resolution, + "camera_fixed": True if camerafixed == "true" else False, + "watermark": True if watermark == "true" else False + } + + # 检查是否为文生视频 (T2V) 模式 + is_t2v = all(item.get("type") == "text" for item in content) + + # 针对 1.5-pro 系列模型,经过测试传参 duration(无论在 body 还是 prompt 中)均会导致 400 错误 + # 官方 1.5 模型目前可能为固定时长,故直接忽略该参数以确保调用成功 + if "doubao-seedance-1-5-pro" in model: + data["generate_audio"] = True + # 不发送 duration 参数 + else: + # 1.0 等旧版模型仍需发送 duration + data["duration"] = duration + + if seed != -1: + data["seed"] = seed + + try: + response = requests.post(api_url, headers=headers, json=data) + if response.status_code == 200: + response_json = response.json() + task_id = response_json.get("id", "") + if task_id: + return (self._poll_task_status(task_id, api_url, api_key),) + else: + return (f"Task ID not found. Response: {response.text}",) + else: + return (f"Failed to create task. HTTP {response.status_code}\nResponse: {response.text}",) + except Exception as e: + return (f"An exception occurred: {str(e)}",) + + +class seedream_api_node: + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "model": ("STRING", {"default": "doubao-seedream-4-0-250828"}), + "prompt": ("STRING", {"forceInput": True}), + "size": (["1K","2K","4K"], {"default": "1K"}), + "api_url": ("STRING", {"default": "https://ark.cn-beijing.volces.com/api/v3/images/generations"}), + "api_key": ("STRING", {"default": "Input_your_API_key_here..."}), + "story_mode": ( + ["disabled", "auto"], + { + "default": "disabled", + "description": "Enable Story Mode for generating images with consistent elements across multiple generations. (Only for Seedream 4.0 and later models)" + }, + ), + }, + "optional": { + "image": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE", "STRING") + RETURN_NAMES = ("image", "response_str") + FUNCTION = "generate_image" + CATEGORY = "BillBum/API Nodes" + + def _to_base64_url_from_input(self, img_input): + # Accept torch tensor (single or batch), PIL Image or numpy array + # If torch tensor batch: iterate and return list of data URLs + urls = [] + if isinstance(img_input, torch.Tensor): + imgs = img_input + if imgs.dim() == 3: + imgs = imgs.unsqueeze(0) + + # downscale large inputs to a reasonable size + samples = imgs.movedim(-1, 1) + total = int(1536 * 1024) + scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2])) + if scale_by < 1: + width = round(samples.shape[3] * scale_by) + height = round(samples.shape[2] * scale_by) + s = common_upscale(samples, width, height, "lanczos", "disabled") + imgs = s.movedim(1, -1) + + for idx in range(imgs.shape[0]): + pil_image = tensor2pil(imgs[idx]) + buffered = io.BytesIO() + pil_image.save(buffered, format="PNG") + img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") + urls.append(f"data:image/png;base64,{img_str}") + return urls + + # single PIL or numpy image + if isinstance(img_input, Image.Image): + pil_image = img_input + else: + try: + pil_image = Image.fromarray(np.array(img_input)) + except Exception: + raise TypeError("Unsupported IMAGE input type") + + buffered = io.BytesIO() + pil_image.save(buffered, format="PNG") + img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") + return [f"data:image/png;base64,{img_str}"] + + def _decode_b64_to_tensor(self, b64_string): + if b64_string.startswith(("data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,")): + b64 = b64_string.split(",", 1)[1] + else: + b64 = b64_string + image_data = base64.b64decode(b64) + image = Image.open(io.BytesIO(image_data)) + return pil2tensor(image) + + @tenacity.retry(wait=tenacity.wait_exponential(multiplier=1.25, min=2, max=20), stop=tenacity.stop_after_attempt(3), reraise=True) + def generate_image(self, model, prompt, size, api_url, api_key, story_mode, image=None): + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + + if image is not None and model.startswith("doubao-seedream-4-5") and size == "1K": + size = "2K" + + payload = { + "model": model, + "prompt": prompt, + "response_format": "b64_json", + "size": size, + "stream": False, + "watermark": False, + } + + if story_mode == "disabled" and model.startswith("doubao-seedream-4"): + payload["sequential_image_generation"] = "disabled" + + if story_mode == "auto" and model.startswith("doubao-seedream-4"): + payload["sequential_image_generation"] = "auto" + + encoded_imgs = [] + if image is not None: + try: + encoded_imgs = self._to_base64_url_from_input(image) + except Exception as e: + return (None, f"Error encoding input image: {e}") + + if encoded_imgs: + if len(encoded_imgs) == 1: + payload["image"] = encoded_imgs[0] + else: + payload["image"] = encoded_imgs + + try: + response = requests.post(api_url, headers=headers, json=payload) + response.raise_for_status() + except Exception as e: + debug_info = { + "request_payload": payload, + "error_message": str(e), + "response_text": getattr(response, 'text', 'No response text available') + } + pretty_debug = json.dumps(debug_info, indent=2, ensure_ascii=False) + return (None, f"Request failed:\n{pretty_debug}") + + try: + response_json = response.json() + except Exception as e: + debug_info = { + "request_payload": payload, + "error_message": str(e), + "raw_response": getattr(response,'text',str(response)) + } + pretty_debug = json.dumps(debug_info, indent=2, ensure_ascii=False) + return (None, f"Failed to parse JSON response:\n{pretty_debug}") + + images_output = [] + data_list = response_json.get("data", []) + if not data_list and "b64_json" in response_json: + data_list = [response_json] + + for item in data_list: + b64_str = item.get("b64_json") + if not b64_str: + continue + try: + img_tensor = self._decode_b64_to_tensor(b64_str) + images_output.append(img_tensor) + except Exception as e: + print(f"Failed decoding an image from response: {e}") + + debug_info = { + "request_payload": payload, + "response": response_json + } + pretty = json.dumps(debug_info, indent=2, ensure_ascii=False) + + if not images_output: + return (None, f"Unexpected or empty image response:\n{pretty}") + + try: + batch = torch.cat(images_output, dim=0) + except Exception: + batch = images_output[0] + + return (batch, pretty) + diff --git a/nodes4tuzi.py b/nodes4tuzi.py index 97ff644..7a297dd 100644 --- a/nodes4tuzi.py +++ b/nodes4tuzi.py @@ -94,7 +94,7 @@ class BillBum_Modified_StreamResponse_LLM_API: encoded_images.append(encoded) return encoded_images - @tenacity.retry(wait=tenacity.wait_exponential(multiplier=1.25, min=5, max=30), stop=tenacity.stop_after_attempt(3)) + @tenacity.retry(wait=tenacity.wait_exponential(multiplier=1.25, min=5, max=30), stop=tenacity.stop_after_attempt(3), reraise=True) def get_llm_stream_response( self, prompt, @@ -145,16 +145,21 @@ class BillBum_Modified_StreamResponse_LLM_API: if extra_body: request_kwargs["extra_body"] = extra_body - completion = client.chat.completions.create(**request_kwargs) + try: + completion = client.chat.completions.create(**request_kwargs) - full_content = "" - for chunk in completion: - if chunk.choices and chunk.choices[0].delta.content is not None: - delta = chunk.choices[0].delta.content - full_content += delta - print(delta, end="") - - return (full_content,) + full_content = "" + for chunk in completion: + if chunk.choices and chunk.choices[0].delta.content is not None: + delta = chunk.choices[0].delta.content + full_content += delta + # print(delta, end="") # For debugging stream output + return (full_content,) + + except Exception as e: + print(f"LLM API Error: {type(e).__name__} - {e}") + # Re-raise the exception to allow tenacity to handle retries + raise class Url2Image: diff --git a/pyproject.toml b/pyproject.toml index e54b3f8..7b10d29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui_billbum_api_nodes" description = "API call node for Third-party platforms both official and local. Support VLMs LLMs Dalle3 Flux-Pro(Support kontext, banana etc... now!! and new Support gpt-image-1!!). And some little tools: img to b64 url, b64 url to img, b64 url to b64 data, reg text to word and ',' only, etc." -version = "1.1.8" +version = "1.1.9" license = {file = "LICENSE"} dependencies = ["tenacity", "openai", "pillow", "requests", "numpy", "tiktoken", "urlextract"]