chengzeyi commited on
Commit
76be525
·
1 Parent(s): 8fc5fa2

Rebuild on shared BYOK template: user-supplied API key, v3 API, Python 3.11

Browse files
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: Hidream E1 Full
3
- emoji: 🖼
4
- colorFrom: purple
5
- colorTo: red
6
- sdk: gradio
7
- sdk_version: 5.25.2
8
- python_version: "3.11"
9
- app_file: app.py
10
  pinned: false
11
- license: apache-2.0
12
- short_description: 'HiDream-E1 is an image editing model built on HiDream-I1. '
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- import os
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
- headers = {
184
- "Content-Type": "application/json",
185
- "Authorization": f"Bearer {API_KEY}",
186
- }
187
- payload = {
188
- "enable_base64_output": True,
189
- "enable_safety_checker": enable_safety,
190
- "image": base64_image,
191
- "prompt": prompt,
192
- "seed": int(seed) if seed != -1 else random.randint(0, 999999)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- inputs=[prompt, image_file],
306
- label="Examples")
307
-
308
- random_btn.click(fn=lambda: random.randint(0, 999999), outputs=seed)
 
309
 
310
- submit_btn.click(
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
- threading.Thread(target=cleanup_task, daemon=True).start()
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

  • SHA256: b1257de01d8440baea06865ea35ce557d885b6dda700281ba25202193c58285c
  • Pointer size: 131 Bytes
  • Size of remote file: 958 kB
examples/controlnet_depth_result-594bc1.png DELETED

Git LFS Details

  • SHA256: bf79fea6fa505595a017c8a880d8d019834a7f81ce2730dbb78b4027a04e2aa6
  • Pointer size: 132 Bytes
  • Size of remote file: 1.45 MB
examples/flux_ip_adapter_input-8dbae5.jpg DELETED

Git LFS Details

  • SHA256: 2be101a7cd4cd45da324a0bac09df7c744ae9adac81ef9ce420ef16764410c6c
  • Pointer size: 131 Bytes
  • Size of remote file: 187 kB
examples/ip_mask_girl2-b393af.png DELETED

Git LFS Details

  • SHA256: d930f1b9cb8862e5a5ba762c6c4ea9cf3d15d0b3b33ace66d0ab475f2da50d9d
  • Pointer size: 132 Bytes
  • Size of remote file: 1.27 MB
examples/penguin-9bdee7.png DELETED

Git LFS Details

  • SHA256: f416333e578b2238bd81a5bfcdd48d03f0f6ea04e4c532caa7ff4b656f08025b
  • Pointer size: 131 Bytes
  • Size of remote file: 613 kB
requirements.txt CHANGED
@@ -1,6 +1,2 @@
1
- gradio
2
- aiohttp
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
+ &middot; <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)