chengzeyi commited on
Commit
9ca6476
·
1 Parent(s): bd30d5a

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

Browse files
Files changed (7) hide show
  1. Dockerfile +8 -6
  2. README.md +26 -2
  3. app.py +47 -508
  4. requirements.txt +2 -0
  5. spaceui.py +240 -0
  6. style.css +131 -0
  7. wavespeed.py +151 -0
Dockerfile CHANGED
@@ -1,14 +1,16 @@
1
- FROM python:3.10-slim
2
 
3
  WORKDIR /app
4
 
5
- RUN pip install --no-cache-dir gradio==3.50.2 requests>=2.28.0
6
-
7
- COPY app.py .
8
 
 
9
 
10
  EXPOSE 7860
11
- ENV GRADIO_SERVER_NAME="0.0.0.0"
12
- ENV GRADIO_SERVER_PORT="7860"
 
 
13
 
14
  CMD ["python", "app.py"]
 
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,10 +1,34 @@
1
  ---
2
- title: FLUX 2 Klein 9B Edit
3
  emoji: 🖼️
4
  colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
7
  pinned: false
8
  license: mit
9
- short_description: AI-powered image editing with FLUX 2 Klein 9B.
10
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FLUX.2 Klein 9B Edit
3
  emoji: 🖼️
4
  colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
7
  pinned: false
8
  license: mit
9
+ short_description: Instruction-driven editing with FLUX.2 Klein 9B.
10
  ---
11
+
12
+ # FLUX.2 Klein 9B Edit
13
+
14
+ Instruction-driven editing with FLUX.2 Klein 9B.
15
+
16
+ Runs [`wavespeed-ai/flux-2-klein-9b/edit`](https://wavespeed.ai/models/wavespeed-ai/flux-2-klein-9b/edit) 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,508 +1,47 @@
1
- """
2
- FLUX 2 Klein 9B Edit - Powered by WaveSpeed AI
3
- Auto-generated by Space Generator
4
- """
5
-
6
- import gradio as gr
7
- import requests
8
- import time
9
- from typing import Dict, Any
10
-
11
- # ============ API Configuration ============
12
- WAVESPEED_API_BASE = "https://api.wavespeed.ai/api/v3"
13
- UPLOAD_ENDPOINT = "https://api.wavespeed.ai/api/v3/media/upload/binary"
14
- MODEL_ENDPOINT = "wavespeed-ai/flux-2-klein-9b/edit"
15
- POLL_INTERVAL = 1.5
16
- POLL_MAX_SECONDS = 120
17
-
18
-
19
-
20
-
21
-
22
- # ============ CSS ============
23
- CUSTOM_CSS = """
24
- /* ===== Base Styles ===== */
25
- html, body, .gradio-container {
26
- background: linear-gradient(145deg, #f5f3ff 0%, #ede9fe 50%, #e0e7ff 100%) !important;
27
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
28
- }
29
-
30
- .gradio-container {
31
- max-width: 960px !important;
32
- margin: 0 auto !important;
33
- padding: 24px !important;
34
- }
35
-
36
- /* ===== Hero Section ===== */
37
- .hero-container {
38
- text-align: center;
39
- padding: 48px 20px 36px;
40
- }
41
-
42
- .hero-badge {
43
- display: inline-block;
44
- background: linear-gradient(135deg, #8b5cf6, #7c3aed);
45
- padding: 10px 24px;
46
- border-radius: 50px;
47
- font-size: 0.7rem;
48
- color: #fff;
49
- font-weight: 700;
50
- letter-spacing: 1.5px;
51
- margin-bottom: 20px;
52
- box-shadow: 0 4px 20px rgba(139, 92, 246, 0.35);
53
- }
54
-
55
- .hero-title {
56
- font-size: 3rem;
57
- font-weight: 800;
58
- margin: 0 0 16px 0;
59
- background: linear-gradient(135deg, #6d28d9 0%, #8b5cf6 50%, #a78bfa 100%);
60
- -webkit-background-clip: text;
61
- -webkit-text-fill-color: transparent;
62
- background-clip: text;
63
- letter-spacing: -0.5px;
64
- }
65
-
66
- .hero-desc {
67
- font-size: 1.05rem;
68
- color: #64748b;
69
- max-width: 100%;
70
- margin: 0 auto 20px;
71
- line-height: 1.6;
72
- }
73
-
74
- .hero-badges {
75
- display: flex;
76
- gap: 28px;
77
- justify-content: center;
78
- flex-wrap: wrap;
79
- }
80
-
81
- .hero-badges span {
82
- display: flex;
83
- align-items: center;
84
- gap: 8px;
85
- color: #475569;
86
- font-size: 0.9rem;
87
- font-weight: 600;
88
- }
89
-
90
- /* ===== Hero Image ===== */
91
- .hero-image {
92
- max-width: 100%;
93
- max-height: 300px;
94
- border-radius: 16px;
95
- margin: 24px auto;
96
- box-shadow: 0 8px 32px rgba(139, 92, 246, 0.15);
97
- }
98
-
99
- /* ===== Main Card ===== */
100
- .main-card {
101
- background: #ffffff;
102
- border: 1px solid rgba(139, 92, 246, 0.1);
103
- border-radius: 20px;
104
- padding: 28px;
105
- margin-bottom: 20px;
106
- box-shadow: 0 4px 24px rgba(139, 92, 246, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
107
- transition: box-shadow 0.3s ease;
108
- }
109
-
110
- .main-card:hover {
111
- box-shadow: 0 8px 32px rgba(139, 92, 246, 0.12), 0 2px 6px rgba(0, 0, 0, 0.04);
112
- }
113
-
114
- /* ===== API Key Section ===== */
115
- .api-key-row {
116
- display: flex;
117
- align-items: center;
118
- justify-content: space-between;
119
- margin-bottom: 12px;
120
- }
121
-
122
- .api-key-label {
123
- display: flex;
124
- align-items: center;
125
- gap: 10px;
126
- color: #1e293b;
127
- font-weight: 700;
128
- font-size: 1rem;
129
- }
130
-
131
- .get-key-btn {
132
- padding: 10px 20px;
133
- background: linear-gradient(135deg, #8b5cf6, #7c3aed);
134
- border: none;
135
- border-radius: 10px;
136
- color: #fff !important;
137
- text-decoration: none;
138
- font-weight: 600;
139
- font-size: 0.85rem;
140
- transition: all 0.25s ease;
141
- box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
142
- }
143
-
144
- .get-key-btn:hover {
145
- transform: translateY(-2px);
146
- box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
147
- color: #fff;
148
- }
149
-
150
- /* ===== Section Title ===== */
151
- .section-title {
152
- color: #1e293b;
153
- font-weight: 700;
154
- font-size: 1rem;
155
- display: flex;
156
- align-items: center;
157
- gap: 10px;
158
- margin-bottom: 16px;
159
- }
160
-
161
- /* ===== Upload Area ===== */
162
- .upload-area {
163
- border: 2px dashed rgba(139, 92, 246, 0.3) !important;
164
- border-radius: 16px !important;
165
- background: linear-gradient(145deg, #faf5ff 0%, #f5f3ff 100%) !important;
166
- transition: all 0.3s ease !important;
167
- min-height: 220px !important;
168
- }
169
-
170
- .upload-area:hover {
171
- border-color: rgba(139, 92, 246, 0.5) !important;
172
- background: linear-gradient(145deg, #f5f3ff 0%, #ede9fe 100%) !important;
173
- }
174
-
175
- /* ===== Result Area ===== */
176
- .result-area {
177
- border: 2px solid rgba(139, 92, 246, 0.15) !important;
178
- border-radius: 16px !important;
179
- background: #fafafa !important;
180
- min-height: 220px !important;
181
- }
182
-
183
- /* ===== Button Styling ===== */
184
- .primary-btn {
185
- width: 100%;
186
- margin-top: 20px !important;
187
- background: linear-gradient(135deg, #8b5cf6, #7c3aed) !important;
188
- border: none !important;
189
- color: #fff !important;
190
- font-weight: 700 !important;
191
- font-size: 1rem !important;
192
- padding: 14px 28px !important;
193
- border-radius: 12px !important;
194
- box-shadow: 0 4px 16px rgba(139, 92, 246, 0.35) !important;
195
- transition: all 0.25s ease !important;
196
- cursor: pointer !important;
197
- }
198
-
199
- .primary-btn:hover {
200
- transform: translateY(-2px) !important;
201
- box-shadow: 0 8px 24px rgba(139, 92, 246, 0.45) !important;
202
- }
203
-
204
- /* ===== CTA Section ===== */
205
- .cta-container {
206
- text-align: center;
207
- padding: 44px 32px;
208
- background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 50%, #6d28d9 100%);
209
- border-radius: 20px;
210
- margin-top: 8px;
211
- box-shadow: 0 8px 32px rgba(139, 92, 246, 0.35);
212
- position: relative;
213
- overflow: hidden;
214
- }
215
-
216
- .cta-container::before {
217
- content: '';
218
- position: absolute;
219
- top: -50%;
220
- right: -50%;
221
- width: 100%;
222
- height: 100%;
223
- background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
224
- pointer-events: none;
225
- }
226
-
227
- .cta-title {
228
- color: #fff;
229
- font-size: 1.5rem;
230
- font-weight: 800;
231
- margin: 0 0 8px 0;
232
- position: relative;
233
- }
234
-
235
- .cta-desc {
236
- color: rgba(255, 255, 255, 0.9);
237
- font-size: 1rem;
238
- margin: 0 0 24px 0;
239
- position: relative;
240
- }
241
-
242
- .cta-btn {
243
- display: inline-block;
244
- padding: 14px 36px;
245
- background: #fff;
246
- border-radius: 12px;
247
- color: #7c3aed !important;
248
- text-decoration: none;
249
- font-weight: 700;
250
- font-size: 1rem;
251
- transition: all 0.25s ease;
252
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
253
- position: relative;
254
- }
255
-
256
- .cta-btn:hover {
257
- transform: translateY(-3px);
258
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
259
- color: #6d28d9 !important;
260
- }
261
-
262
- /* ===== Hide Elements ===== */
263
- footer { display: none !important; }
264
-
265
- /* ===== Input Styling ===== */
266
- .gradio-container input[type="password"],
267
- .gradio-container input[type="text"] {
268
- border: 2px solid #e2e8f0 !important;
269
- border-radius: 12px !important;
270
- padding: 14px 16px !important;
271
- font-size: 0.95rem !important;
272
- transition: all 0.2s ease !important;
273
- }
274
-
275
- .gradio-container input[type="password"]:focus,
276
- .gradio-container input[type="text"]:focus {
277
- border-color: #8b5cf6 !important;
278
- box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15) !important;
279
- outline: none !important;
280
- }
281
- """
282
-
283
-
284
- # ============ API Functions ============
285
- def upload_image(api_key: str, file_path: str) -> str:
286
- headers = {"Authorization": f"Bearer {api_key.strip()}"}
287
- with open(file_path, "rb") as f:
288
- resp = requests.post(UPLOAD_ENDPOINT, headers=headers, files={"file": f}, timeout=60)
289
- if resp.status_code == 401:
290
- raise Exception("Invalid API Key")
291
- elif resp.status_code >= 400:
292
- raise Exception(f"Upload failed: {resp.status_code}")
293
- data = resp.json()
294
- if data.get("code") != 200:
295
- raise Exception(data.get("message", "Upload failed"))
296
- return data.get("data", {}).get("download_url")
297
-
298
-
299
- def call_api(api_key: str, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
300
- headers = {"Authorization": f"Bearer {api_key.strip()}", "Content-Type": "application/json"}
301
- resp = requests.post(f"{WAVESPEED_API_BASE}/{endpoint}", json=payload, headers=headers, timeout=30)
302
- if resp.status_code == 401:
303
- raise Exception("Invalid API Key")
304
- elif resp.status_code == 429:
305
- raise Exception("Quota exceeded")
306
- elif resp.status_code >= 400:
307
- raise Exception(f"API error: {resp.status_code}")
308
- data = resp.json()
309
- if data.get("code") != 200:
310
- raise Exception(data.get("message", "Unknown error"))
311
- return data.get("data", {})
312
-
313
-
314
- def poll_result(api_key: str, request_id: str) -> Dict[str, Any]:
315
- headers = {"Authorization": f"Bearer {api_key.strip()}"}
316
- url = f"{WAVESPEED_API_BASE}/predictions/{request_id}/result"
317
- start_time = time.time()
318
- while time.time() - start_time < POLL_MAX_SECONDS:
319
- resp = requests.get(url, headers=headers, timeout=30)
320
- if resp.status_code >= 400:
321
- raise Exception("Failed to get result")
322
- result = resp.json().get("data", {})
323
- status = result.get("status", "")
324
- if status == "completed":
325
- return result
326
- elif status == "failed":
327
- raise Exception("Generation failed")
328
- time.sleep(POLL_INTERVAL)
329
- raise Exception("Timeout")
330
-
331
-
332
- def download_video(url: str) -> str:
333
- """下载视频到临时文件,返回文件路径"""
334
- import tempfile
335
- import os
336
- try:
337
- resp = requests.get(url, timeout=120, stream=True)
338
- if resp.status_code != 200:
339
- return None
340
- # 从 URL 或 Content-Type 确定扩展名
341
- ext = ".mp4"
342
- if "webm" in url.lower() or "webm" in resp.headers.get("content-type", ""):
343
- ext = ".webm"
344
- # 创建临时文件
345
- fd, path = tempfile.mkstemp(suffix=ext)
346
- with os.fdopen(fd, 'wb') as f:
347
- for chunk in resp.iter_content(chunk_size=8192):
348
- f.write(chunk)
349
- return path
350
- except Exception as e:
351
- print(f"Download error: {e}")
352
- return None
353
-
354
-
355
- def process(api_key: str, image_path: str, prompt: str):
356
- if not api_key or not api_key.strip():
357
- gr.Warning("Please enter your API Key")
358
- return None
359
- if not image_path:
360
- gr.Warning("Please upload an image")
361
- return None
362
- if not prompt or not prompt.strip():
363
- gr.Warning("Please enter a prompt")
364
- return None
365
-
366
- try:
367
- gr.Info("Uploading image...")
368
- image_url = upload_image(api_key, image_path)
369
-
370
- gr.Info("Processing...")
371
- payload = {
372
- "images": [image_url],
373
- "prompt": prompt.strip(),
374
- "enable_base64_output": False,
375
- "enable_sync_mode": False,
376
- "seed": -1,
377
- }
378
- result = call_api(api_key, MODEL_ENDPOINT, payload)
379
- request_id = result.get("id")
380
- if not request_id:
381
- gr.Warning("Failed to start")
382
- return None
383
-
384
- final_result = poll_result(api_key, request_id)
385
- outputs = final_result.get("outputs", [])
386
- if outputs:
387
- gr.Info("Done!")
388
- return outputs[0]
389
- return None
390
-
391
- except Exception as e:
392
- error_msg = str(e).lower()
393
- if "invalid" in error_msg or "401" in error_msg:
394
- gr.Warning("Invalid API Key")
395
- elif "quota" in error_msg or "429" in error_msg:
396
- gr.Warning("Quota exceeded")
397
- elif "timeout" in error_msg:
398
- gr.Warning("Timeout - please try again")
399
- else:
400
- gr.Warning(str(e))
401
- return None
402
-
403
-
404
- # ============ Gradio UI ============
405
- with gr.Blocks(css=CUSTOM_CSS, title="FLUX 2 Klein 9B Edit - WaveSpeed") as demo:
406
-
407
- # Hero Section
408
- gr.HTML("""
409
- <div class="hero-container">
410
- <div class="hero-badge">WAVESPEED AI</div>
411
- <h1 class="hero-title">FLUX 2 Klein 9B Edit</h1>
412
- <p class="hero-desc">AI-powered image editing with FLUX 2 Klein 9B. Try on <a href="https://wavespeed.ai/models?utm_source=huggingface_space_flux2_klein_9b_edit" target="_blank" style="color: #8b5cf6; text-decoration: none; font-weight: 600;">wavespeed</a></p>
413
- <div class="hero-badges">
414
- <span>
415
- <svg width="18" height="18" fill="#10b981" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"/></svg>
416
- Fast Processing
417
- </span>
418
- <span>
419
- <svg width="18" height="18" fill="#10b981" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"/></svg>
420
- High Quality
421
- </span>
422
- <span>
423
- <svg width="18" height="18" fill="#10b981" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"/></svg>
424
- Easy to Use
425
- </span>
426
- </div>
427
- </div>
428
- """)
429
-
430
-
431
-
432
- # API Key Card
433
- with gr.Group(elem_classes="main-card"):
434
- gr.HTML("""
435
- <div class="api-key-row">
436
- <span class="api-key-label">
437
- <svg width="20" height="20" fill="#8b5cf6" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clip-rule="evenodd"/></svg>
438
- API Key
439
- </span>
440
- <a href="https://wavespeed.ai/models?utm_source=huggingface_space_flux2_klein_9b_edit" target="_blank" class="get-key-btn">Get API Key</a>
441
- </div>
442
- """)
443
- api_key_input = gr.Textbox(
444
- placeholder="Enter your WaveSpeed API key",
445
- type="password",
446
- show_label=False
447
- )
448
-
449
- # Main Content Card
450
- with gr.Group(elem_classes="main-card"):
451
- with gr.Row():
452
- # Left Column - Input
453
- with gr.Column(scale=1):
454
- gr.HTML("""
455
- <div class="section-title">
456
- <svg width="20" height="20" fill="#8b5cf6" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/></svg>
457
- Upload Image
458
- </div>
459
- """)
460
- image_input = gr.Image(
461
- label="Upload Image",
462
- type="filepath",
463
- source="upload",
464
- elem_classes=["upload-area"]
465
- )
466
- prompt_input = gr.Textbox(
467
- label="Prompt",
468
- placeholder="Describe what you want...",
469
- lines=3
470
- )
471
- submit_btn = gr.Button("Process", variant="primary", elem_classes="primary-btn")
472
-
473
- # Right Column - Output
474
- with gr.Column(scale=1):
475
- gr.HTML("""
476
- <div class="section-title">
477
- <svg width="20" height="20" fill="#8b5cf6" viewBox="0 0 20 20"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
478
- Result
479
- </div>
480
- """)
481
- output_image = gr.Image(
482
- label="",
483
- type="filepath",
484
- interactive=False,
485
- elem_classes=["hide-label", "result-area"]
486
- )
487
-
488
- # CTA Section
489
- gr.HTML("""
490
- <div class="cta-container">
491
- <h3 class="cta-title">Want More Features?</h3>
492
- <p class="cta-desc">Higher resolutions, batch processing, and 700+ AI models</p>
493
- <a href="https://wavespeed.ai/models?utm_source=huggingface_space_flux2_klein_9b_edit" target="_blank" class="cta-btn">
494
- Explore WaveSpeed.ai
495
- </a>
496
- </div>
497
- """)
498
-
499
- # Event binding
500
- submit_btn.click(
501
- fn=process,
502
- inputs=[api_key_input, image_input, prompt_input],
503
- outputs=output_image,
504
- )
505
-
506
-
507
- if __name__ == "__main__":
508
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
1
+ """FLUX.2 Klein 9B Edit — 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": "FLUX.2 Klein 9B Edit",
18
+ "model": "wavespeed-ai/flux-2-klein-9b/edit",
19
+ "output": "image",
20
+ "tagline": "Instruction-driven editing with FLUX.2 Klein 9B.",
21
+ "fields": [
22
+ {
23
+ "kind": "images",
24
+ "key": "images",
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"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
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)