Add files via upload
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,20 @@
|
||||
# 🌊 ComfyUI-WaveSpeed
|
||||
|
||||
[](LICENSE)
|
||||
[](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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user