diff --git a/ComfyUI-WaveSpeed/LICENSE b/ComfyUI-WaveSpeed/LICENSE new file mode 100644 index 0000000..6da3b20 --- /dev/null +++ b/ComfyUI-WaveSpeed/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 fmartinellidev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/ComfyUI-WaveSpeed/README.md b/ComfyUI-WaveSpeed/README.md new file mode 100644 index 0000000..40023fe --- /dev/null +++ b/ComfyUI-WaveSpeed/README.md @@ -0,0 +1,20 @@ +# 🌊 ComfyUI-WaveSpeed + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![ComfyUI](https://img.shields.io/badge/ComfyUI-Node-blueviolet)](https://github.com/comfyanonymous/ComfyUI) + +NĂł **genĂ©rico e flexĂ­vel** para integrar **qualquer modelo do [WaveSpeed](https://wavespeed.ai)** no ComfyUI — incluindo Nano Banana Pro, Wan 2.1, Flux, Kling, Veo e mais. + +✅ Upload direto no WaveSpeed CDN +✅ Barra de progresso no terminal (igual ao KSampler) +✅ Suporte a imagem e vĂ­deo +✅ `endpoint_url` customizĂĄvel — basta colar a URL do playground! + +--- + +## 📩 Instalação + +No seu terminal (dentro da pasta `ComfyUI/custom_nodes`): + +```bash +git clone https://github.com/fmartinellidev/ComfyUI-WaveSpeed.git \ No newline at end of file diff --git a/ComfyUI-WaveSpeed/__init__.py b/ComfyUI-WaveSpeed/__init__.py new file mode 100644 index 0000000..1120bb8 --- /dev/null +++ b/ComfyUI-WaveSpeed/__init__.py @@ -0,0 +1,18 @@ +# __init__.py — wavespeed +from .node_wavespeed_api import WaveSpeedAPINode +from .node_apikey_loader import WaveSpeedAPIKeyLoader +from .node_image_uploader import WaveSpeedImageUploader + +NODE_CLASS_MAPPINGS = { + "WaveSpeedAPI": WaveSpeedAPINode, # ← ID genĂ©rico + "WaveSpeedAPIKeyLoader": WaveSpeedAPIKeyLoader, + "WaveSpeedImageUploader": WaveSpeedImageUploader, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "WaveSpeedAPI": "WaveSpeed API (Generic)", # ← nome claro e reutilizĂĄvel + "WaveSpeedAPIKeyLoader": "WaveSpeed API Key Loader", + "WaveSpeedImageUploader": "WaveSpeed Image Uploader (Official)", +} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] \ No newline at end of file diff --git a/ComfyUI-WaveSpeed/nodes/node_apikey_loader.py b/ComfyUI-WaveSpeed/nodes/node_apikey_loader.py new file mode 100644 index 0000000..c52bb6c --- /dev/null +++ b/ComfyUI-WaveSpeed/nodes/node_apikey_loader.py @@ -0,0 +1,26 @@ +# node_apikey_loader.py + +class WaveSpeedAPIKeyLoader: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "api_key": ("STRING", {"multiline": False, "default": "", "placeholder": "ex: ws_sk_xxxxxxxx"}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("api_key",) + FUNCTION = "load" + CATEGORY = "api node/utility/WaveSpeed" + DISPLAY_NAME = "WaveSpeed API Key Loader" + NODE_ID = "WaveSpeedAPIKeyLoader" + + def load(self, api_key): + key = api_key.strip() + if not key: + raise ValueError("API key nĂŁo pode estar vazia.") + #if not key.startswith("ws_sk_"): + # Apenas aviso — nĂŁo impede (algumas chaves podem nĂŁo seguir padrĂŁo) + #print("⚠ Aviso: sua chave nĂŁo começa com 'ws_sk_' — verifique se estĂĄ correta.") + return (key,) \ No newline at end of file diff --git a/ComfyUI-WaveSpeed/nodes/node_image_uploader.py b/ComfyUI-WaveSpeed/nodes/node_image_uploader.py new file mode 100644 index 0000000..9c7b8a6 --- /dev/null +++ b/ComfyUI-WaveSpeed/nodes/node_image_uploader.py @@ -0,0 +1,68 @@ +# node_image_uploader.py +import requests +import torch +import numpy as np +from PIL import Image +from io import BytesIO +import os + +class WaveSpeedImageUploader: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "api_key": ("STRING", { + "multiline": False, + "default": "", + "placeholder": "ou defina WAVESPEED_API_KEY" + }), + "output_format": (["png", "jpeg"], {"default": "png"}) + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("image_url",) + FUNCTION = "upload" + CATEGORY = "api node/image/WaveSpeed" + DISPLAY_NAME = "WaveSpeed Image Uploader (Official)" + NODE_ID = "WaveSpeedImageUploader" + + def upload(self, image: torch.Tensor, api_key: str, output_format: str): + final_api_key = api_key.strip() or os.getenv("WAVESPEED_API_KEY", "").strip() + if not final_api_key: + raise ValueError("API key nĂŁo fornecida.") + + if image.ndim == 4: + image = image[0] + + img_np = (image.cpu().numpy() * 255).astype("uint8") + pil_img = Image.fromarray(img_np, mode="RGB") + + buffer = BytesIO() + if output_format == "png": + pil_img.save(buffer, format="PNG") + else: + pil_img.save(buffer, format="JPEG", quality=95) + buffer.seek(0) + + upload_url = "https://api.wavespeed.ai/api/v3/media/upload/binary" + headers = {"Authorization": f"Bearer {final_api_key}"} + files = {"file": (f"image.{output_format}", buffer, f"image/{output_format}")} + + try: + print("đŸ“€ Upload oficial no WaveSpeed...") + response = requests.post(upload_url, headers=headers, files=files, timeout=30) + response.raise_for_status() + data = response.json() + + # ✅ Extrai download_url de data e remove espaços + image_url = data.get("data", {}).get("download_url", "").strip() + if not image_url.startswith("http"): + raise ValueError(f"download_url nĂŁo encontrada. Resposta: {data}") + + print(f"✅ URL oficial do WaveSpeed: {image_url}") + return (image_url,) + + except Exception as e: + raise RuntimeError(f"Falha no upload: {e}") \ No newline at end of file diff --git a/ComfyUI-WaveSpeed/nodes/node_wavespeed_api.py b/ComfyUI-WaveSpeed/nodes/node_wavespeed_api.py new file mode 100644 index 0000000..fd6ee34 --- /dev/null +++ b/ComfyUI-WaveSpeed/nodes/node_wavespeed_api.py @@ -0,0 +1,179 @@ +# node_wavespeed_api.py +import requests +import time +import os +import sys +import torch +import numpy as np +from PIL import Image +from io import BytesIO + +def _print_progress(percent, status="processing", width=40): + filled = int(width * percent / 100) + bar = "█" * filled + "░" * (width - filled) + sys.stdout.write(f"\r⏳ [{bar}] {int(percent)}% | {status}") + sys.stdout.flush() + +def _clear_progress(): + sys.stdout.write("\r" + " " * 80 + "\r") + sys.stdout.flush() + +class WaveSpeedAPINode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "prompt": ("STRING", {"multiline": True, "default": "make her smile, studio lighting"}), + "endpoint_url": ("STRING", { + "multiline": False, + "default": "https://api.wavespeed.ai/api/v3/google/nano-banana-pro/edit" + }), + "poll_interval": ("INT", {"default": 3, "min": 1, "max": 10}), + }, + "optional": { + "image_url": ("STRING", {"multiline": False, "default": ""}), + "image": ("IMAGE",), + "api_key": ("STRING", {"multiline": False, "default": ""}), + "extra_payload": ("STRING", { + "multiline": True, + "default": "{}", + "placeholder": "JSON adicional (ex: {\"resolution\": \"2k\"})" + }), + } + } + + RETURN_TYPES = ("IMAGE", "STRING", "STRING") + RETURN_NAMES = ("image", "request_id", "raw_response") + FUNCTION = "generate" + NODE_ID = "WaveSpeedAPI" + DISPLAY_NAME = "WaveSpeed API (Generic)" + CATEGORY = "api node/image/WaveSpeed" + + def generate(self, prompt, endpoint_url, poll_interval, image_url="", image=None, api_key="", extra_payload="{}"): + # 1. Resolve API key + final_api_key = api_key.strip() or os.getenv("WAVESPEED_API_KEY", "").strip() + if not final_api_key: + raise ValueError("API key nĂŁo fornecida.") + + # 2. Resolve imagem + if image is not None: + print("đŸ“€ Upload da imagem no WaveSpeed CDN...") + image_url = self._upload_to_wavespeed(image, final_api_key) + print(f"✅ URL oficial: {image_url}") + elif not image_url.strip(): + # ❗ Alguns endpoints nĂŁo exigem imagem (ex: text-to-video) + print("⚠ Nenhuma imagem fornecida — verifique se o endpoint a suporta.") + + # 3. Monta payload base + payload = {"prompt": prompt} + if image_url: + payload["images"] = [image_url] + + # 4. Adiciona extra_payload (JSON seguro) + import json + try: + extra = json.loads(extra_payload) if extra_payload.strip() else {} + payload.update(extra) + except Exception as e: + raise ValueError(f"extra_payload invĂĄlido (deve ser JSON): {e}") + + # 5. Headers + headers = { + "Authorization": f"Bearer {final_api_key}", + "Content-Type": "application/json" + } + + # 6. POST + print(f"đŸ“€ Enviando para: {endpoint_url}") + response = requests.post(endpoint_url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + resp_json = response.json() + raw_response_str = json.dumps(resp_json, indent=2) + + data = resp_json.get("data", {}) + request_id = data.get("id") + get_url = data.get("urls", {}).get("get", "").strip() + + if not request_id or not get_url: + raise RuntimeError(f"Resposta invĂĄlida. Campos ausentes: id={bool(request_id)}, get_url={bool(get_url)}") + + print(f"\n🚀 Job iniciado: {request_id}") + + # 7. Polling com progresso + current_percent = 0 + ESTIMATED_TOTAL_SECONDS = 20 # adaptĂĄvel + steps = max(1, int(ESTIMATED_TOTAL_SECONDS / poll_interval)) + increment = 99.0 / steps + + try: + while True: + time.sleep(poll_interval) + result = requests.get(get_url, headers=headers, timeout=30) + result.raise_for_status() + poll_data = result.json() + poll_result = poll_data.get("data", {}) + status = poll_result.get("status") + + if status == "completed": + _print_progress(100.0, "done") + print() + outputs = poll_result.get("outputs", []) + if not outputs: + _clear_progress() + raise RuntimeError("Nenhum output retornado.") + # Suporta image ou video + output_url = outputs[0] + break + + elif status == "failed": + _clear_progress() + error = poll_result.get("error", "Erro desconhecido") + raise RuntimeError(f"Falha: {error}") + + elif status in ("pending", "processing", "created"): + current_percent = min(99, current_percent + increment) + _print_progress(current_percent, status) + + else: + _clear_progress() + raise RuntimeError(f"Status inesperado: {status}") + + except Exception as e: + _clear_progress() + raise + + # 8. Download (suporte a imagem apenas — para vĂ­deo, retornar URL) + if output_url.lower().endswith(('.png', '.jpg', '.jpeg')): + print("đŸ“„ Baixando imagem...") + img_response = requests.get(output_url, timeout=30) + img_response.raise_for_status() + pil_img = Image.open(BytesIO(img_response.content)).convert("RGB") + image_np = np.array(pil_img).astype(np.float32) / 255.0 + image_tensor = torch.from_numpy(image_np)[None,] + return (image_tensor, request_id, raw_response_str) + else: + # Para vĂ­deos, retornar placeholder preto + URL no raw_response + print(f"🎬 SaĂ­da Ă© um vĂ­deo: {output_url}") + placeholder = torch.zeros((1, 512, 512, 3), dtype=torch.float32) + return (placeholder, request_id, raw_response_str + f"\n\nVIDEO_URL: {output_url}") + + def _upload_to_wavespeed(self, image_tensor: torch.Tensor, api_key: str, output_format="png"): + if image_tensor.ndim == 4: + image_tensor = image_tensor[0] + img_np = (image_tensor.cpu().numpy() * 255).astype("uint8") + pil_img = Image.fromarray(img_np, mode="RGB") + buffer = BytesIO() + pil_img.save(buffer, format=output_format.upper()) + buffer.seek(0) + + upload_url = "https://api.wavespeed.ai/api/v3/media/upload/binary" + headers = {"Authorization": f"Bearer {api_key}"} + files = {"file": (f"img.{output_format}", buffer, f"image/{output_format}")} + + response = requests.post(upload_url, headers=headers, files=files, timeout=30) + response.raise_for_status() + data = response.json() + image_url = data.get("data", {}).get("download_url", "").strip() + if not image_url.startswith("http"): + raise ValueError(f"download_url invĂĄlida: {data}") + return image_url \ No newline at end of file diff --git a/ComfyUI-WaveSpeed/nodes/utils.py b/ComfyUI-WaveSpeed/nodes/utils.py new file mode 100644 index 0000000..f8fe5b3 --- /dev/null +++ b/ComfyUI-WaveSpeed/nodes/utils.py @@ -0,0 +1,32 @@ +# utils.py — versĂŁo final e funcional (comprovada por vocĂȘ) +import requests +from PIL import Image +from io import BytesIO + +def image_to_temp_url(image_tensor, output_format="jpeg"): + if image_tensor.ndim == 4: + image_tensor = image_tensor[0] + + img_np = (image_tensor.cpu().numpy() * 255).astype("uint8") + pil_img = Image.fromarray(img_np, mode="RGB") + + buffer = BytesIO() + pil_img.save(buffer, format=output_format.upper()) + buffer.seek(0) + + try: + # ✅ Funcionando: temp.sh + User-Agent vĂĄlido + response = requests.post( + "https://temp.sh/upload", + files={"file": (f"img.{output_format}", buffer, f"image/{output_format}")}, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + } + ) + response.raise_for_status() + url = response.text.strip() + if not url.startswith("http"): + raise ValueError(f"URL invĂĄlida: {url}") + return url + except Exception as e: + raise RuntimeError(f"Falha ao fazer upload da imagem: {e}") \ No newline at end of file diff --git a/ComfyUI-WaveSpeed/requirements.txt b/ComfyUI-WaveSpeed/requirements.txt new file mode 100644 index 0000000..e69de29