Add files via upload

This commit is contained in:
Luis Fernando Martinelli
2026-01-03 18:33:23 -03:00
committed by GitHub
parent c183e1ad08
commit 957db3beb0
8 changed files with 364 additions and 0 deletions
+21
View File
@@ -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.
+20
View File
@@ -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
+18
View File
@@ -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"]
@@ -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,)
@@ -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}")
@@ -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
+32
View File
@@ -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}")
View File