Spaces:
Sleeping
Sleeping
Rebuild on shared BYOK template: user-supplied API key, v3 API, Python 3.11
Browse files- Dockerfile +16 -0
- README.md +30 -11
- app.py +39 -312
- examples/astronaut-2e89be.jpg +0 -3
- examples/controlnet_depth_result-594bc1.png +0 -3
- examples/flux_ip_adapter_input-8dbae5.jpg +0 -3
- examples/ip_mask_girl2-b393af.png +0 -3
- examples/penguin-9bdee7.png +0 -3
- requirements.txt +2 -6
- spaceui.py +240 -0
- style.css +131 -0
- wavespeed.py +151 -0
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
ENV GRADIO_SERVER_NAME="0.0.0.0" \
|
| 12 |
+
GRADIO_SERVER_PORT="7860" \
|
| 13 |
+
GRADIO_ANALYTICS_ENABLED="False" \
|
| 14 |
+
HF_HUB_DISABLE_TELEMETRY="1"
|
| 15 |
+
|
| 16 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,15 +1,34 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🖼
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
sdk_version: 5.25.2
|
| 8 |
-
python_version: "3.11"
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
license:
|
| 12 |
-
short_description:
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: HiDream-E1 Full
|
| 3 |
+
emoji: 🖼️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
short_description: Image editing built on HiDream-I1.
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# HiDream-E1 Full
|
| 13 |
+
|
| 14 |
+
Image editing built on HiDream-I1.
|
| 15 |
+
|
| 16 |
+
Runs [`wavespeed-ai/hidream-e1-full`](https://wavespeed.ai/models/wavespeed-ai/hidream-e1-full) through the
|
| 17 |
+
[WaveSpeed](https://wavespeed.ai) v3 API.
|
| 18 |
+
|
| 19 |
+
## Bring your own API key
|
| 20 |
+
|
| 21 |
+
This Space does not ship an API key. Paste your own in the field at the top —
|
| 22 |
+
create one in the [WaveSpeed dashboard](https://wavespeed.ai/dashboard).
|
| 23 |
+
|
| 24 |
+
Your key is sent only to `api.wavespeed.ai` to run the model. It is not stored,
|
| 25 |
+
not logged, and not written to disk; it is stripped out of error messages
|
| 26 |
+
before they are displayed, and Gradio's flagging and API-page features (both of
|
| 27 |
+
which would otherwise record or republish inputs) are disabled. Generations are
|
| 28 |
+
billed to your own account.
|
| 29 |
+
|
| 30 |
+
## Development
|
| 31 |
+
|
| 32 |
+
Source of truth is `_shared/` in the working repo, not this copy:
|
| 33 |
+
|
| 34 |
+
python3 _shared/build_apps.py
|
app.py
CHANGED
|
@@ -1,320 +1,47 @@
|
|
| 1 |
-
|
| 2 |
-
import requests
|
| 3 |
-
import json
|
| 4 |
-
import time
|
| 5 |
-
import threading
|
| 6 |
-
import uuid
|
| 7 |
-
import base64
|
| 8 |
-
from pathlib import Path
|
| 9 |
-
from dotenv import load_dotenv
|
| 10 |
-
import gradio as gr
|
| 11 |
-
import random
|
| 12 |
-
import torch
|
| 13 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 14 |
-
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 15 |
-
|
| 16 |
-
# 环境变量加载
|
| 17 |
-
load_dotenv()
|
| 18 |
-
API_KEY = os.getenv("WAVESPEED_API_KEY")
|
| 19 |
-
# Missing credentials are reported when a request is made, not at import.
|
| 20 |
-
# Raising here took the whole Space down with nothing but a traceback.
|
| 21 |
-
|
| 22 |
-
# 安全分类配置
|
| 23 |
-
MODEL_URL = "TostAI/nsfw-text-detection-large"
|
| 24 |
-
CLASS_NAMES = {0: "✅ SAFE", 1: "⚠️ QUESTIONABLE", 2: "🚫 UNSAFE"}
|
| 25 |
-
|
| 26 |
-
# 加载安全模型
|
| 27 |
-
# The safety classifier is loaded on first use rather than at import. Loading
|
| 28 |
-
# it at import meant a multi-hundred-MB download had to finish before the app
|
| 29 |
-
# could bind its port, so the Space was killed as unhealthy before it ever
|
| 30 |
-
# served a request. If the model cannot be loaded we raise, so a classifier
|
| 31 |
-
# failure never silently leaves prompts unscreened.
|
| 32 |
-
tokenizer = None
|
| 33 |
-
model = None
|
| 34 |
-
_classifier_lock = threading.Lock()
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def _ensure_classifier():
|
| 38 |
-
global tokenizer, model
|
| 39 |
-
if tokenizer is not None and model is not None:
|
| 40 |
-
return
|
| 41 |
-
with _classifier_lock:
|
| 42 |
-
if tokenizer is None or model is None:
|
| 43 |
-
tokenizer = AutoTokenizer.from_pretrained(MODEL_URL)
|
| 44 |
-
model = AutoModelForSequenceClassification.from_pretrained(MODEL_URL)
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
# 会话管理
|
| 48 |
-
class SessionManager:
|
| 49 |
-
_instances = {}
|
| 50 |
-
_lock = threading.Lock()
|
| 51 |
-
|
| 52 |
-
@classmethod
|
| 53 |
-
def get_session(cls, session_id):
|
| 54 |
-
with cls._lock:
|
| 55 |
-
if session_id not in cls._instances:
|
| 56 |
-
cls._instances[session_id] = {
|
| 57 |
-
'count': 0,
|
| 58 |
-
'history': [],
|
| 59 |
-
'last_active': time.time()
|
| 60 |
-
}
|
| 61 |
-
return cls._instances[session_id]
|
| 62 |
-
|
| 63 |
-
@classmethod
|
| 64 |
-
def cleanup_sessions(cls):
|
| 65 |
-
with cls._lock:
|
| 66 |
-
now = time.time()
|
| 67 |
-
expired = [
|
| 68 |
-
k for k, v in cls._instances.items()
|
| 69 |
-
if now - v['last_active'] > 3600
|
| 70 |
-
]
|
| 71 |
-
for k in expired:
|
| 72 |
-
del cls._instances[k]
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
# 速率限制
|
| 76 |
-
class RateLimiter:
|
| 77 |
-
|
| 78 |
-
def __init__(self):
|
| 79 |
-
self.clients = {}
|
| 80 |
-
self.lock = threading.Lock()
|
| 81 |
-
|
| 82 |
-
def check(self, client_id):
|
| 83 |
-
with self.lock:
|
| 84 |
-
now = time.time()
|
| 85 |
-
if client_id not in self.clients:
|
| 86 |
-
self.clients[client_id] = {'count': 1, 'reset': now + 3600}
|
| 87 |
-
return True
|
| 88 |
-
if now > self.clients[client_id]['reset']:
|
| 89 |
-
self.clients[client_id] = {'count': 1, 'reset': now + 3600}
|
| 90 |
-
return True
|
| 91 |
-
if self.clients[client_id]['count'] >= 20:
|
| 92 |
-
return False
|
| 93 |
-
self.clients[client_id]['count'] += 1
|
| 94 |
-
return True
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
session_manager = SessionManager()
|
| 98 |
-
rate_limiter = RateLimiter()
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
# 工具函数
|
| 102 |
-
def create_error_image(message):
|
| 103 |
-
"""生成错误提示图片"""
|
| 104 |
-
img = Image.new("RGB", (512, 512), "#ffdddd")
|
| 105 |
-
try:
|
| 106 |
-
font = ImageFont.truetype("arial.ttf", 24)
|
| 107 |
-
except:
|
| 108 |
-
font = ImageFont.load_default()
|
| 109 |
-
draw = ImageDraw.Draw(img)
|
| 110 |
-
text = f"Error: {message[:60]}..." if len(message) > 60 else message
|
| 111 |
-
draw.text((50, 200), text, fill="#ff0000", font=font)
|
| 112 |
-
return img
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
@torch.no_grad()
|
| 116 |
-
def classify_prompt(prompt):
|
| 117 |
-
_ensure_classifier()
|
| 118 |
-
"""安全分类"""
|
| 119 |
-
inputs = tokenizer(prompt,
|
| 120 |
-
return_tensors="pt",
|
| 121 |
-
truncation=True,
|
| 122 |
-
max_length=512)
|
| 123 |
-
outputs = model(**inputs)
|
| 124 |
-
return torch.argmax(outputs.logits).item()
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def image_to_base64(file_path):
|
| 128 |
-
"""将图片转换为Base64格式"""
|
| 129 |
-
with open(file_path, "rb") as f:
|
| 130 |
-
file_ext = Path(file_path).suffix.lower()[1:]
|
| 131 |
-
mime_type = f"image/{file_ext}" if file_ext in ["jpeg", "jpg", "png"
|
| 132 |
-
] else "image/jpeg"
|
| 133 |
-
return f"data:{mime_type};base64,{base64.b64encode(f.read()).decode()}"
|
| 134 |
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
|
| 137 |
-
def generate_image(image_file, prompt, seed, session_id, enable_safety=True):
|
| 138 |
-
try:
|
| 139 |
-
# 安全检查
|
| 140 |
-
if enable_safety:
|
| 141 |
-
safety_level = classify_prompt(prompt)
|
| 142 |
-
if safety_level != 0:
|
| 143 |
-
error_img = create_error_image(CLASS_NAMES[safety_level])
|
| 144 |
-
yield f"❌ Blocked: {CLASS_NAMES[safety_level]}", error_img, ""
|
| 145 |
-
return
|
| 146 |
-
|
| 147 |
-
# 速率限制
|
| 148 |
-
if not rate_limiter.check(session_id):
|
| 149 |
-
error_img = create_error_image(
|
| 150 |
-
"Hourly limit exceeded (20 requests)")
|
| 151 |
-
yield "❌ 请求过于频繁,请稍后再试", error_img, ""
|
| 152 |
-
return
|
| 153 |
-
|
| 154 |
-
# 会��更新
|
| 155 |
-
session = session_manager.get_session(session_id)
|
| 156 |
-
session['last_active'] = time.time()
|
| 157 |
-
session['count'] += 1
|
| 158 |
-
|
| 159 |
-
# 输入验证
|
| 160 |
-
error_messages = []
|
| 161 |
-
if not image_file:
|
| 162 |
-
error_messages.append("请上传图片文件")
|
| 163 |
-
elif not Path(image_file).exists():
|
| 164 |
-
error_messages.append("文件不存在")
|
| 165 |
-
elif Path(image_file).suffix.lower()[1:] not in ["jpg", "jpeg", "png"]:
|
| 166 |
-
error_messages.append("仅支持JPG/PNG格式")
|
| 167 |
-
if not prompt.strip():
|
| 168 |
-
error_messages.append("提示语不能为空")
|
| 169 |
-
if error_messages:
|
| 170 |
-
error_img = create_error_image(" | ".join(error_messages))
|
| 171 |
-
yield "❌ 输入验证失败", error_img, ""
|
| 172 |
-
return
|
| 173 |
-
|
| 174 |
-
# 转换为Base64
|
| 175 |
-
try:
|
| 176 |
-
base64_image = image_to_base64(image_file)
|
| 177 |
-
except Exception as e:
|
| 178 |
-
error_img = create_error_image(f"文件处理失败: {str(e)}")
|
| 179 |
-
yield "❌ 文件处理失败", error_img, ""
|
| 180 |
-
return
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
}
|
| 194 |
-
|
| 195 |
-
# 提交请求
|
| 196 |
-
response = requests.post(
|
| 197 |
-
"https://api.wavespeed.ai/api/v2/wavespeed-ai/hidream-e1-full",
|
| 198 |
-
headers=headers,
|
| 199 |
-
json=payload,
|
| 200 |
-
timeout=30)
|
| 201 |
-
response.raise_for_status()
|
| 202 |
-
|
| 203 |
-
# 处理响应
|
| 204 |
-
request_id = response.json()["data"]["id"]
|
| 205 |
-
result_url = f"https://api.wavespeed.ai/api/v2/predictions/{request_id}/result"
|
| 206 |
-
start_time = time.time()
|
| 207 |
-
|
| 208 |
-
# 轮询结果
|
| 209 |
-
for _ in range(60):
|
| 210 |
-
time.sleep(1)
|
| 211 |
-
resp = requests.get(result_url, headers=headers)
|
| 212 |
-
resp.raise_for_status()
|
| 213 |
-
|
| 214 |
-
data = resp.json()["data"]
|
| 215 |
-
status = data["status"]
|
| 216 |
-
|
| 217 |
-
if status == "completed":
|
| 218 |
-
elapsed = time.time() - start_time
|
| 219 |
-
image_url = data["outputs"][0]
|
| 220 |
-
session["history"].append(image_url)
|
| 221 |
-
yield f"🎉 生成成功! 耗时 {elapsed:.1f}s", image_url, image_url
|
| 222 |
-
return
|
| 223 |
-
elif status == "failed":
|
| 224 |
-
raise Exception(data.get("error", "Unknown error"))
|
| 225 |
-
else:
|
| 226 |
-
yield f"⏳ 当前状态: {status.capitalize()}...", None, None
|
| 227 |
-
|
| 228 |
-
raise Exception("生成超时")
|
| 229 |
-
|
| 230 |
-
except Exception as e:
|
| 231 |
-
error_img = create_error_image(str(e))
|
| 232 |
-
yield f"❌ 生成失败: {str(e)}", error_img, ""
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
# 后台清理任务
|
| 236 |
-
def cleanup_task():
|
| 237 |
-
while True:
|
| 238 |
-
session_manager.cleanup_sessions()
|
| 239 |
-
time.sleep(3600)
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
# 界面构建
|
| 243 |
-
with gr.Blocks(theme=gr.themes.Soft(),
|
| 244 |
-
css="""
|
| 245 |
-
.status-box { padding: 10px; border-radius: 5px; margin: 5px; }
|
| 246 |
-
.safe { background: #e8f5e9; border: 1px solid #a5d6a7; }
|
| 247 |
-
.warning { background: #fff3e0; border: 1px solid #ffcc80; }
|
| 248 |
-
.error { background: #ffebee; border: 1px solid #ef9a9a; }
|
| 249 |
-
""") as app:
|
| 250 |
-
|
| 251 |
-
session_id = gr.State(str(uuid.uuid4()))
|
| 252 |
-
|
| 253 |
-
gr.Markdown("# 🖼️Hidream-E1-Full Live On Wavespeed Ai")
|
| 254 |
-
gr.Markdown("HiDream-E1 is an image editing model built on HiDream-I1.")
|
| 255 |
-
|
| 256 |
-
with gr.Row():
|
| 257 |
-
with gr.Column(scale=1):
|
| 258 |
-
image_file = gr.Image(label="Upload Image",
|
| 259 |
-
type="filepath",
|
| 260 |
-
sources=["upload"],
|
| 261 |
-
interactive=True,
|
| 262 |
-
image_mode="RGB")
|
| 263 |
-
prompt = gr.Textbox(
|
| 264 |
-
label="prompt",
|
| 265 |
-
placeholder="Please enter an English prompt...",
|
| 266 |
-
lines=3)
|
| 267 |
-
seed = gr.Number(label="seed",
|
| 268 |
-
value=-1,
|
| 269 |
-
minimum=-1,
|
| 270 |
-
maximum=999999,
|
| 271 |
-
step=1)
|
| 272 |
-
random_btn = gr.Button("random🎲seed", variant="secondary")
|
| 273 |
-
enable_safety = gr.Checkbox(label="🔒 Enable Safety Checker",
|
| 274 |
-
value=True,
|
| 275 |
-
interactive=False)
|
| 276 |
-
with gr.Column(scale=1):
|
| 277 |
-
output_image = gr.Image(label="Generated Result")
|
| 278 |
-
output_url = gr.Textbox(label="image url",
|
| 279 |
-
interactive=True,
|
| 280 |
-
visible=False)
|
| 281 |
-
status = gr.Textbox(label="Status", elem_classes=["status-box"])
|
| 282 |
-
submit_btn = gr.Button("开始生成", variant="primary")
|
| 283 |
-
gr.Examples(examples=[
|
| 284 |
-
[
|
| 285 |
-
"Convert the image into Claymation style.",
|
| 286 |
-
"examples/penguin-9bdee7.png"
|
| 287 |
-
],
|
| 288 |
-
[
|
| 289 |
-
"Convert the image into a Ghibli style.",
|
| 290 |
-
"examples/flux_ip_adapter_input-8dbae5.jpg"
|
| 291 |
-
],
|
| 292 |
-
[
|
| 293 |
-
"Add sunglasses to the face of the girl.",
|
| 294 |
-
"examples/ip_mask_girl2-b393af.png"
|
| 295 |
-
],
|
| 296 |
-
[
|
| 297 |
-
'Convert the image into an ink sketch style.',
|
| 298 |
-
"examples/astronaut-2e89be.jpg"
|
| 299 |
-
],
|
| 300 |
-
[
|
| 301 |
-
'Add a butterfly to the scene.',
|
| 302 |
-
"examples/controlnet_depth_result-594bc1.png"
|
| 303 |
-
]
|
| 304 |
],
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
|
|
|
| 309 |
|
| 310 |
-
|
| 311 |
-
generate_image,
|
| 312 |
-
inputs=[image_file, prompt, seed, session_id, enable_safety],
|
| 313 |
-
outputs=[status, output_image, output_url])
|
| 314 |
|
| 315 |
if __name__ == "__main__":
|
| 316 |
-
|
| 317 |
-
app.queue(max_size=4).launch(server_name="0.0.0.0",
|
| 318 |
-
server_port=7860,
|
| 319 |
-
show_error=True,
|
| 320 |
-
share=False)
|
|
|
|
| 1 |
+
"""HiDream-E1 Full — powered by WaveSpeed AI.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
Generated from _shared/apps.py — edit the spec there, not this file.
|
| 4 |
+
"""
|
| 5 |
|
| 6 |
+
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
+
# Set before gradio is imported: its telemetry client reads these at import time.
|
| 9 |
+
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
|
| 10 |
+
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
|
| 11 |
+
|
| 12 |
+
from pathlib import Path # noqa: E402
|
| 13 |
+
|
| 14 |
+
import spaceui # noqa: E402
|
| 15 |
+
|
| 16 |
+
SPEC = {
|
| 17 |
+
"title": "HiDream-E1 Full",
|
| 18 |
+
"model": "wavespeed-ai/hidream-e1-full",
|
| 19 |
+
"output": "image",
|
| 20 |
+
"tagline": "Image editing built on HiDream-I1.",
|
| 21 |
+
"fields": [
|
| 22 |
+
{
|
| 23 |
+
"kind": "image",
|
| 24 |
+
"key": "image",
|
| 25 |
+
"label": "Input image"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"kind": "prompt",
|
| 29 |
+
"key": "prompt",
|
| 30 |
+
"label": "Prompt",
|
| 31 |
+
"placeholder": "Describe the edit to apply"
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"kind": "seed",
|
| 35 |
+
"key": "seed"
|
| 36 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
],
|
| 38 |
+
"extra": {
|
| 39 |
+
"enable_base64_output": False,
|
| 40 |
+
"enable_sync_mode": False
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
|
| 44 |
+
SPEC["css"] = Path(__file__).with_name("style.css").read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
if __name__ == "__main__":
|
| 47 |
+
spaceui.launch(spaceui.build(SPEC), SPEC["css"])
|
|
|
|
|
|
|
|
|
|
|
|
examples/astronaut-2e89be.jpg
DELETED
Git LFS Details
|
examples/controlnet_depth_result-594bc1.png
DELETED
Git LFS Details
|
examples/flux_ip_adapter_input-8dbae5.jpg
DELETED
Git LFS Details
|
examples/ip_mask_girl2-b393af.png
DELETED
Git LFS Details
|
examples/penguin-9bdee7.png
DELETED
Git LFS Details
|
requirements.txt
CHANGED
|
@@ -1,6 +1,2 @@
|
|
| 1 |
-
gradio
|
| 2 |
-
|
| 3 |
-
python-dotenv
|
| 4 |
-
torch
|
| 5 |
-
transformers>=4.40
|
| 6 |
-
requests
|
|
|
|
| 1 |
+
gradio>=6.22,<7
|
| 2 |
+
requests>=2.31
|
|
|
|
|
|
|
|
|
|
|
|
spaceui.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Builds a Space UI from a declarative spec.
|
| 2 |
+
|
| 3 |
+
Shared verbatim by every Space in the wavespeed org. Generated from
|
| 4 |
+
_shared/spaceapp/ — edit there and re-run _shared/build_apps.py.
|
| 5 |
+
|
| 6 |
+
Handling of the user's API key
|
| 7 |
+
------------------------------
|
| 8 |
+
The key is typed into the browser and travels to this server on each request.
|
| 9 |
+
Keeping it from leaking takes more than masking the textbox, because Gradio has
|
| 10 |
+
several features that will happily persist or republish an input:
|
| 11 |
+
|
| 12 |
+
* `type="password"` - not echoed back into the DOM.
|
| 13 |
+
* `api_visibility="private"` - Gradio 6 otherwise documents this event on the
|
| 14 |
+
app's public API page, generating client
|
| 15 |
+
snippets that include every input, the key
|
| 16 |
+
among them.
|
| 17 |
+
* `analytics_enabled=False` - on Blocks, plus the env vars set in app.py
|
| 18 |
+
before gradio is imported.
|
| 19 |
+
* no `gr.State`/`gr.Examples` ever holds the key, so it is not serialised
|
| 20 |
+
into the page or cached to disk.
|
| 21 |
+
* every error is passed through `wavespeed.redact` before display, because a
|
| 22 |
+
requests exception can stringify the Authorization header.
|
| 23 |
+
|
| 24 |
+
Gradio's flagging feature (which writes raw inputs to a CSV) belongs to
|
| 25 |
+
gr.Interface; this UI is built from gr.Blocks, which has no flagging, so there
|
| 26 |
+
is nothing to switch off there.
|
| 27 |
+
|
| 28 |
+
The key is a plain function argument: it lives for the duration of one request
|
| 29 |
+
and is not retained between them.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import gradio as gr
|
| 35 |
+
|
| 36 |
+
import wavespeed as ws
|
| 37 |
+
|
| 38 |
+
DASH = "https://wavespeed.ai/dashboard"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _model_link(model: str) -> str:
|
| 42 |
+
return f"https://wavespeed.ai/models/{model}"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _collect(spec, key, values, progress):
|
| 46 |
+
"""Turn UI values into an API payload, uploading any local files first."""
|
| 47 |
+
payload = dict(spec.get("extra", {}))
|
| 48 |
+
for field, value in zip(spec["fields"], values):
|
| 49 |
+
kind, api_key_name = field["kind"], field["key"]
|
| 50 |
+
|
| 51 |
+
if kind in ("image", "audio", "video"):
|
| 52 |
+
if not value:
|
| 53 |
+
if field.get("required", True):
|
| 54 |
+
raise ws.WaveSpeedError(f"{field['label']} is required.")
|
| 55 |
+
continue
|
| 56 |
+
progress(0.1, desc=f"Uploading {field['label'].lower()}…")
|
| 57 |
+
payload[api_key_name] = ws.upload(key, value)
|
| 58 |
+
|
| 59 |
+
elif kind == "images":
|
| 60 |
+
if not value:
|
| 61 |
+
if field.get("required", True):
|
| 62 |
+
raise ws.WaveSpeedError(f"{field['label']} is required.")
|
| 63 |
+
continue
|
| 64 |
+
progress(0.1, desc=f"Uploading {field['label'].lower()}…")
|
| 65 |
+
payload[api_key_name] = [ws.upload(key, value)]
|
| 66 |
+
|
| 67 |
+
elif kind == "prompt":
|
| 68 |
+
text = (value or "").strip()
|
| 69 |
+
if not text and field.get("required", True):
|
| 70 |
+
raise ws.WaveSpeedError("Enter a prompt.")
|
| 71 |
+
if text:
|
| 72 |
+
payload[api_key_name] = text
|
| 73 |
+
|
| 74 |
+
elif kind == "seed":
|
| 75 |
+
# -1 means "let the service choose"; sending it would pin the seed.
|
| 76 |
+
if value is not None and int(value) >= 0:
|
| 77 |
+
payload[api_key_name] = int(value)
|
| 78 |
+
|
| 79 |
+
elif value is not None and value != "":
|
| 80 |
+
payload[api_key_name] = value
|
| 81 |
+
|
| 82 |
+
return payload
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def build(spec):
|
| 86 |
+
"""Return a configured gr.Blocks for this Space."""
|
| 87 |
+
css = spec["css"]
|
| 88 |
+
outputs_are_video = spec["output"] == "video"
|
| 89 |
+
|
| 90 |
+
with gr.Blocks(
|
| 91 |
+
title=f"{spec['title']} - WaveSpeed AI",
|
| 92 |
+
analytics_enabled=False,
|
| 93 |
+
) as demo:
|
| 94 |
+
gr.HTML(
|
| 95 |
+
f"""
|
| 96 |
+
<div class="hero-container">
|
| 97 |
+
<div class="hero-badge">WAVESPEED AI</div>
|
| 98 |
+
<h1 class="hero-title">{spec['title']}</h1>
|
| 99 |
+
<p class="hero-desc">{spec['tagline']}</p>
|
| 100 |
+
</div>
|
| 101 |
+
"""
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
with gr.Row(elem_classes="api-key-row"):
|
| 105 |
+
api_key = gr.Textbox(
|
| 106 |
+
label="WaveSpeed API key",
|
| 107 |
+
placeholder="Paste your API key — it is used for this request only",
|
| 108 |
+
type="password", # never echoed back to the page
|
| 109 |
+
show_label=False,
|
| 110 |
+
container=False,
|
| 111 |
+
scale=4,
|
| 112 |
+
)
|
| 113 |
+
gr.HTML(
|
| 114 |
+
f'<a class="get-key-btn" href="{DASH}" target="_blank" '
|
| 115 |
+
'rel="noopener">Get a key</a>'
|
| 116 |
+
)
|
| 117 |
+
gr.Markdown(
|
| 118 |
+
"Your key is sent only to `api.wavespeed.ai` to run this model. "
|
| 119 |
+
"It is not stored, logged, or shared, and generations are billed to "
|
| 120 |
+
"your own account.",
|
| 121 |
+
elem_classes="key-note",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
controls = []
|
| 125 |
+
with gr.Row():
|
| 126 |
+
with gr.Column(scale=1):
|
| 127 |
+
for f in spec["fields"]:
|
| 128 |
+
controls.append(_make_control(f))
|
| 129 |
+
run_btn = gr.Button(
|
| 130 |
+
spec.get("button", "Generate"),
|
| 131 |
+
variant="primary",
|
| 132 |
+
elem_classes="primary-btn",
|
| 133 |
+
)
|
| 134 |
+
with gr.Column(scale=1):
|
| 135 |
+
if spec["output"] == "compare":
|
| 136 |
+
outs = [
|
| 137 |
+
gr.Image(label=m["label"], type="filepath")
|
| 138 |
+
for m in spec["compare"]
|
| 139 |
+
]
|
| 140 |
+
elif outputs_are_video:
|
| 141 |
+
outs = [gr.Video(label="Result")]
|
| 142 |
+
else:
|
| 143 |
+
outs = [gr.Image(label="Result", type="filepath")]
|
| 144 |
+
|
| 145 |
+
gr.HTML(
|
| 146 |
+
f"""
|
| 147 |
+
<div class="cta-container">
|
| 148 |
+
<p class="cta-desc">Model:
|
| 149 |
+
<a href="{_model_link(spec['model'])}" target="_blank" rel="noopener">
|
| 150 |
+
<code>{spec['model']}</code></a>
|
| 151 |
+
· <a href="https://wavespeed.ai/docs" target="_blank"
|
| 152 |
+
rel="noopener">API docs</a>
|
| 153 |
+
</p>
|
| 154 |
+
</div>
|
| 155 |
+
"""
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
def _run(key, *values, progress=gr.Progress()):
|
| 159 |
+
blank = [None] * len(outs)
|
| 160 |
+
if not key or not key.strip():
|
| 161 |
+
gr.Warning("Enter your WaveSpeed API key first.")
|
| 162 |
+
return blank[0] if len(blank) == 1 else tuple(blank)
|
| 163 |
+
try:
|
| 164 |
+
payload = _collect(spec, key, values, progress)
|
| 165 |
+
progress(0.3, desc="Submitting…")
|
| 166 |
+
if spec["output"] == "compare":
|
| 167 |
+
models = spec["compare"]
|
| 168 |
+
results = []
|
| 169 |
+
for i, m in enumerate(models):
|
| 170 |
+
progress(
|
| 171 |
+
0.3 + 0.6 * i / len(models),
|
| 172 |
+
desc=f"Running {m['label']}…",
|
| 173 |
+
)
|
| 174 |
+
merged = dict(payload, **m.get("extra", {}))
|
| 175 |
+
results.append(ws.run(key, m["model"], merged)[0])
|
| 176 |
+
return tuple(results)
|
| 177 |
+
outputs = ws.run(
|
| 178 |
+
key, spec["model"], payload,
|
| 179 |
+
on_tick=lambda s: progress(0.6, desc=f"Generating ({s})…"),
|
| 180 |
+
)
|
| 181 |
+
return outputs[0]
|
| 182 |
+
except ws.WaveSpeedError as e:
|
| 183 |
+
# Message is already redacted by the client.
|
| 184 |
+
gr.Warning(str(e))
|
| 185 |
+
except Exception as e: # noqa: BLE001 - never surface a raw trace
|
| 186 |
+
gr.Warning(ws.redact(f"Unexpected error: {e}", key))
|
| 187 |
+
return blank[0] if len(blank) == 1 else tuple(blank)
|
| 188 |
+
|
| 189 |
+
run_btn.click(
|
| 190 |
+
_run,
|
| 191 |
+
inputs=[api_key, *controls],
|
| 192 |
+
outputs=outs,
|
| 193 |
+
# Keep this event off the public API page — its generated snippets
|
| 194 |
+
# would include the api_key input.
|
| 195 |
+
api_visibility="private",
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
return demo
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _make_control(f):
|
| 202 |
+
# seed fields carry no explicit label; they get the default below.
|
| 203 |
+
kind, label = f["kind"], f.get("label", "")
|
| 204 |
+
if kind == "prompt":
|
| 205 |
+
return gr.Textbox(
|
| 206 |
+
label=label, placeholder=f.get("placeholder", ""),
|
| 207 |
+
lines=f.get("lines", 3),
|
| 208 |
+
)
|
| 209 |
+
if kind == "image":
|
| 210 |
+
return gr.Image(label=label, type="filepath")
|
| 211 |
+
if kind == "images":
|
| 212 |
+
return gr.Image(label=label, type="filepath")
|
| 213 |
+
if kind == "audio":
|
| 214 |
+
return gr.Audio(label=label, type="filepath")
|
| 215 |
+
if kind == "video":
|
| 216 |
+
return gr.Video(label=label)
|
| 217 |
+
if kind == "choice":
|
| 218 |
+
return gr.Dropdown(
|
| 219 |
+
label=label, choices=f["choices"], value=f.get("default", f["choices"][0])
|
| 220 |
+
)
|
| 221 |
+
if kind == "bool":
|
| 222 |
+
return gr.Checkbox(label=label, value=f.get("default", False))
|
| 223 |
+
if kind == "seed":
|
| 224 |
+
return gr.Number(label=f.get("label", "Seed (-1 = random)"), value=-1, precision=0)
|
| 225 |
+
if kind == "slider":
|
| 226 |
+
return gr.Slider(
|
| 227 |
+
label=label, minimum=f["min"], maximum=f["max"],
|
| 228 |
+
step=f.get("step", 1), value=f["default"],
|
| 229 |
+
)
|
| 230 |
+
raise ValueError(f"unknown field kind: {kind}")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def launch(demo, css):
|
| 234 |
+
"""Launch the app. Gradio 6 takes css here rather than on Blocks."""
|
| 235 |
+
demo.launch(
|
| 236 |
+
server_name="0.0.0.0",
|
| 237 |
+
server_port=7860,
|
| 238 |
+
css=css,
|
| 239 |
+
quiet=True,
|
| 240 |
+
)
|
style.css
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Shared Space styling. Generated from _shared/spaceapp/ — do not edit in place. */
|
| 2 |
+
|
| 3 |
+
.gradio-container {
|
| 4 |
+
max-width: 1080px !important;
|
| 5 |
+
margin: 0 auto !important;
|
| 6 |
+
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
| 7 |
+
"Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif !important;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
/* --- hero ---------------------------------------------------------------- */
|
| 11 |
+
|
| 12 |
+
.hero-container {
|
| 13 |
+
text-align: center;
|
| 14 |
+
padding: 40px 16px 24px;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.hero-badge {
|
| 18 |
+
display: inline-block;
|
| 19 |
+
background: linear-gradient(135deg, #4f46e5, #2563eb);
|
| 20 |
+
color: #fff;
|
| 21 |
+
font-size: 0.66rem;
|
| 22 |
+
font-weight: 700;
|
| 23 |
+
letter-spacing: 0.14em;
|
| 24 |
+
padding: 7px 18px;
|
| 25 |
+
border-radius: 999px;
|
| 26 |
+
margin-bottom: 16px;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.hero-title {
|
| 30 |
+
font-size: 2.4rem;
|
| 31 |
+
font-weight: 800;
|
| 32 |
+
letter-spacing: -0.025em;
|
| 33 |
+
margin: 0 0 10px;
|
| 34 |
+
background: linear-gradient(135deg, #4338ca 0%, #2563eb 55%, #7c3aed 100%);
|
| 35 |
+
-webkit-background-clip: text;
|
| 36 |
+
background-clip: text;
|
| 37 |
+
-webkit-text-fill-color: transparent;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.hero-desc {
|
| 41 |
+
font-size: 1rem;
|
| 42 |
+
line-height: 1.6;
|
| 43 |
+
color: var(--body-text-color-subdued, #64748b);
|
| 44 |
+
max-width: 62ch;
|
| 45 |
+
margin: 0 auto;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
@media (max-width: 620px) {
|
| 49 |
+
.hero-title {
|
| 50 |
+
font-size: 1.8rem;
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/* --- api key row --------------------------------------------------------- */
|
| 55 |
+
|
| 56 |
+
.api-key-row {
|
| 57 |
+
align-items: center;
|
| 58 |
+
gap: 10px;
|
| 59 |
+
margin-bottom: 4px;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.get-key-btn {
|
| 63 |
+
display: inline-block;
|
| 64 |
+
white-space: nowrap;
|
| 65 |
+
padding: 9px 16px;
|
| 66 |
+
border-radius: 8px;
|
| 67 |
+
border: 1px solid var(--border-color-primary, #d2d6de);
|
| 68 |
+
font-size: 0.86rem;
|
| 69 |
+
font-weight: 600;
|
| 70 |
+
text-decoration: none;
|
| 71 |
+
color: var(--body-text-color, #14161a);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.get-key-btn:hover {
|
| 75 |
+
background: var(--background-fill-secondary, #f4f5f7);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.key-note {
|
| 79 |
+
font-size: 0.82rem !important;
|
| 80 |
+
line-height: 1.55;
|
| 81 |
+
color: var(--body-text-color-subdued, #6b7280) !important;
|
| 82 |
+
margin: 2px 2px 18px !important;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.key-note code {
|
| 86 |
+
font-size: 0.95em;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/* --- controls ------------------------------------------------------------ */
|
| 90 |
+
|
| 91 |
+
.primary-btn {
|
| 92 |
+
background: linear-gradient(135deg, #4f46e5, #2563eb) !important;
|
| 93 |
+
border: 0 !important;
|
| 94 |
+
color: #fff !important;
|
| 95 |
+
font-weight: 650 !important;
|
| 96 |
+
border-radius: 9px !important;
|
| 97 |
+
margin-top: 8px;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.primary-btn:hover {
|
| 101 |
+
filter: brightness(1.08);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* --- footer -------------------------------------------------------------- */
|
| 105 |
+
|
| 106 |
+
.cta-container {
|
| 107 |
+
text-align: center;
|
| 108 |
+
padding: 22px 16px 8px;
|
| 109 |
+
margin-top: 18px;
|
| 110 |
+
border-top: 1px solid var(--border-color-primary, #e4e6eb);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.cta-desc {
|
| 114 |
+
font-size: 0.86rem;
|
| 115 |
+
color: var(--body-text-color-subdued, #6b7280);
|
| 116 |
+
margin: 0;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.cta-desc a {
|
| 120 |
+
color: #4f46e5;
|
| 121 |
+
text-decoration: none;
|
| 122 |
+
font-weight: 600;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.cta-desc a:hover {
|
| 126 |
+
text-decoration: underline;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.cta-desc code {
|
| 130 |
+
font-size: 0.95em;
|
| 131 |
+
}
|
wavespeed.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal WaveSpeed v3 API client.
|
| 2 |
+
|
| 3 |
+
Shared verbatim by every Space in the wavespeed org. Generated from
|
| 4 |
+
_shared/spaceapp/ — edit there and re-run _shared/build_apps.py, never edit the
|
| 5 |
+
copy inside a Space.
|
| 6 |
+
|
| 7 |
+
The user supplies their own API key through the UI. It is used to sign requests
|
| 8 |
+
to api.wavespeed.ai and nothing else: it is never logged, never written to
|
| 9 |
+
disk, never placed in a Gradio component value, and is stripped out of every
|
| 10 |
+
error message before that message can reach a browser (see `redact`). An
|
| 11 |
+
exception raised by `requests` can carry the full request headers in its text,
|
| 12 |
+
which is exactly how a key ends up in a user-visible traceback, so every raise
|
| 13 |
+
in this module goes through `redact` first.
|
| 14 |
+
|
| 15 |
+
API reference: https://wavespeed.ai/docs/rest-api
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import time
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
import requests
|
| 24 |
+
|
| 25 |
+
API_BASE = "https://api.wavespeed.ai/api/v3"
|
| 26 |
+
UPLOAD_URL = f"{API_BASE}/media/upload/binary"
|
| 27 |
+
|
| 28 |
+
# The docs ask for >= 2s between polls of the same task, easing toward 5-10s
|
| 29 |
+
# for long jobs. Anything faster risks being throttled.
|
| 30 |
+
POLL_START = 2.0
|
| 31 |
+
POLL_MAX = 8.0
|
| 32 |
+
POLL_GROWTH = 1.25
|
| 33 |
+
POLL_TIMEOUT = 600
|
| 34 |
+
|
| 35 |
+
TERMINAL_OK = "completed"
|
| 36 |
+
TERMINAL_BAD = ("failed", "cancelled", "timeout")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class WaveSpeedError(Exception):
|
| 40 |
+
"""User-facing error. The message is always key-free."""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def redact(text: Any, key: str | None) -> str:
|
| 44 |
+
"""Remove the API key (and any bearer token) from text headed for a user."""
|
| 45 |
+
s = str(text)
|
| 46 |
+
if key:
|
| 47 |
+
k = key.strip()
|
| 48 |
+
if k:
|
| 49 |
+
s = s.replace(k, "***")
|
| 50 |
+
# Defend against a partially-quoted key in a repr.
|
| 51 |
+
if len(k) > 12:
|
| 52 |
+
s = s.replace(k[:12], "***")
|
| 53 |
+
# Catch any Authorization header echoed by a library.
|
| 54 |
+
import re
|
| 55 |
+
|
| 56 |
+
s = re.sub(r"(?i)(bearer\s+)[A-Za-z0-9._\-]+", r"\1***", s)
|
| 57 |
+
s = re.sub(r"(?i)('authorization':\s*')[^']*", r"\1***", s)
|
| 58 |
+
return s
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _headers(key: str, json: bool = False) -> dict:
|
| 62 |
+
h = {"Authorization": f"Bearer {key.strip()}"}
|
| 63 |
+
if json:
|
| 64 |
+
h["Content-Type"] = "application/json"
|
| 65 |
+
return h
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _check(resp: requests.Response, key: str) -> dict:
|
| 69 |
+
if resp.status_code == 401:
|
| 70 |
+
raise WaveSpeedError("Invalid API key. Check the key and try again.")
|
| 71 |
+
if resp.status_code == 402:
|
| 72 |
+
raise WaveSpeedError("This account is out of credit.")
|
| 73 |
+
if resp.status_code == 429:
|
| 74 |
+
raise WaveSpeedError("Rate limit or quota exceeded. Wait and retry.")
|
| 75 |
+
if resp.status_code >= 400:
|
| 76 |
+
raise WaveSpeedError(
|
| 77 |
+
redact(f"API error {resp.status_code}: {resp.text[:300]}", key)
|
| 78 |
+
)
|
| 79 |
+
try:
|
| 80 |
+
body = resp.json()
|
| 81 |
+
except ValueError:
|
| 82 |
+
raise WaveSpeedError("API returned a non-JSON response.") from None
|
| 83 |
+
if body.get("code") != 200:
|
| 84 |
+
raise WaveSpeedError(redact(body.get("message", "Unknown API error"), key))
|
| 85 |
+
return body.get("data", {}) or {}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def upload(key: str, path: str) -> str:
|
| 89 |
+
"""Upload a local file, returning the URL to reference it by."""
|
| 90 |
+
try:
|
| 91 |
+
with open(path, "rb") as fh:
|
| 92 |
+
resp = requests.post(
|
| 93 |
+
UPLOAD_URL, headers=_headers(key), files={"file": fh}, timeout=120
|
| 94 |
+
)
|
| 95 |
+
except requests.RequestException as e:
|
| 96 |
+
raise WaveSpeedError(redact(f"Upload failed: {e}", key)) from None
|
| 97 |
+
data = _check(resp, key)
|
| 98 |
+
url = data.get("download_url") or data.get("url")
|
| 99 |
+
if not url:
|
| 100 |
+
raise WaveSpeedError("Upload succeeded but returned no URL.")
|
| 101 |
+
return url
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def submit(key: str, model: str, payload: dict) -> str:
|
| 105 |
+
"""Start a job and return its request id.
|
| 106 |
+
|
| 107 |
+
Deliberately not retried: the docs warn that repeating a POST can bill the
|
| 108 |
+
caller twice. Only the GET poll below is safe to retry.
|
| 109 |
+
"""
|
| 110 |
+
try:
|
| 111 |
+
resp = requests.post(
|
| 112 |
+
f"{API_BASE}/{model}", headers=_headers(key, json=True), json=payload, timeout=60
|
| 113 |
+
)
|
| 114 |
+
except requests.RequestException as e:
|
| 115 |
+
raise WaveSpeedError(redact(f"Could not reach the API: {e}", key)) from None
|
| 116 |
+
data = _check(resp, key)
|
| 117 |
+
rid = data.get("id")
|
| 118 |
+
if not rid:
|
| 119 |
+
raise WaveSpeedError("API accepted the request but returned no task id.")
|
| 120 |
+
return rid
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def poll(key: str, request_id: str, on_tick=None) -> list[str]:
|
| 124 |
+
"""Poll a task to completion and return its output URLs."""
|
| 125 |
+
url = f"{API_BASE}/predictions/{request_id}/result"
|
| 126 |
+
deadline = time.time() + POLL_TIMEOUT
|
| 127 |
+
delay = POLL_START
|
| 128 |
+
while time.time() < deadline:
|
| 129 |
+
time.sleep(delay)
|
| 130 |
+
delay = min(delay * POLL_GROWTH, POLL_MAX)
|
| 131 |
+
try:
|
| 132 |
+
resp = requests.get(url, headers=_headers(key), timeout=60)
|
| 133 |
+
except requests.RequestException:
|
| 134 |
+
continue # transient; a GET is safe to repeat
|
| 135 |
+
data = _check(resp, key)
|
| 136 |
+
status = data.get("status", "")
|
| 137 |
+
if status == TERMINAL_OK:
|
| 138 |
+
outputs = data.get("outputs") or []
|
| 139 |
+
if not outputs:
|
| 140 |
+
raise WaveSpeedError("Generation finished but produced no output.")
|
| 141 |
+
return outputs
|
| 142 |
+
if status in TERMINAL_BAD:
|
| 143 |
+
detail = redact(data.get("error") or status, key)
|
| 144 |
+
raise WaveSpeedError(f"Generation {status}: {detail}")
|
| 145 |
+
if on_tick:
|
| 146 |
+
on_tick(status)
|
| 147 |
+
raise WaveSpeedError("Timed out waiting for the result. The job may still finish.")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def run(key: str, model: str, payload: dict, on_tick=None) -> list[str]:
|
| 151 |
+
return poll(key, submit(key, model, payload), on_tick=on_tick)
|