chengzeyi commited on
Commit
98bd119
·
1 Parent(s): 6f02bc5

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

Browse files
Files changed (7) hide show
  1. Dockerfile +16 -14
  2. README.md +34 -10
  3. app.py +50 -524
  4. requirements.txt +2 -9
  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
- COPY requirements.txt .
6
- RUN pip install --no-cache-dir -r requirements.txt
7
-
8
- COPY app.py .
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: Image Expand
3
- emoji: 🖼️
4
- colorFrom: blue
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: AI-powered image expansion
10
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Image Expand
3
+ emoji: 🖼️
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ short_description: Extend an image beyond its original frame (outpainting).
10
+ ---
11
+
12
+ # Image Expand
13
+
14
+ Extend an image beyond its original frame (outpainting).
15
+
16
+ Runs [`bria/expand`](https://wavespeed.ai/models/bria/expand) 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,524 +1,50 @@
1
- """
2
- Bria AI x WaveSpeed AI - Image Expand
3
- Clean Light Theme for Gradio 3.x
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 = "bria/expand"
15
- POLL_INTERVAL = 1.5
16
- POLL_MAX_SECONDS = 120
17
-
18
- # ============ Aspect Ratios ============
19
- ASPECT_RATIOS = ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"]
20
-
21
- # ============ Enhanced Light Theme CSS ============
22
- CUSTOM_CSS = """
23
- /* ===== Base Styles ===== */
24
- html, body, .gradio-container {
25
- background: linear-gradient(145deg, #f5f3ff 0%, #ede9fe 50%, #e0e7ff 100%) !important;
26
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
27
- }
28
-
29
- .gradio-container {
30
- max-width: 960px !important;
31
- margin: 0 auto !important;
32
- padding: 24px !important;
33
- }
34
-
35
- /* ===== Hero Section ===== */
36
- .hero-container {
37
- text-align: center;
38
- padding: 48px 20px 36px;
39
- }
40
-
41
- .hero-badge {
42
- display: inline-block;
43
- background: linear-gradient(135deg, #8b5cf6, #7c3aed);
44
- padding: 10px 24px;
45
- border-radius: 50px;
46
- font-size: 0.7rem;
47
- color: #fff;
48
- font-weight: 700;
49
- letter-spacing: 1.5px;
50
- margin-bottom: 20px;
51
- box-shadow: 0 4px 20px rgba(139, 92, 246, 0.35);
52
- }
53
-
54
- .hero-title {
55
- font-size: 3rem;
56
- font-weight: 800;
57
- margin: 0 0 16px 0;
58
- background: linear-gradient(135deg, #6d28d9 0%, #8b5cf6 50%, #a78bfa 100%);
59
- -webkit-background-clip: text;
60
- -webkit-text-fill-color: transparent;
61
- background-clip: text;
62
- letter-spacing: -0.5px;
63
- }
64
-
65
- .hero-desc {
66
- font-size: 1.05rem;
67
- color: #64748b;
68
- max-width: 100%;
69
- margin: 0 auto 20px;
70
- line-height: 1.6;
71
- }
72
-
73
- .hero-badges {
74
- display: flex;
75
- gap: 28px;
76
- justify-content: center;
77
- flex-wrap: wrap;
78
- }
79
-
80
- .hero-badges span {
81
- display: flex;
82
- align-items: center;
83
- gap: 8px;
84
- color: #475569;
85
- font-size: 0.9rem;
86
- font-weight: 600;
87
- }
88
-
89
- /* ===== Main Card ===== */
90
- .main-card {
91
- background: #ffffff;
92
- border: 1px solid rgba(139, 92, 246, 0.1);
93
- border-radius: 20px;
94
- padding: 28px;
95
- margin-bottom: 20px;
96
- box-shadow: 0 4px 24px rgba(139, 92, 246, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
97
- transition: box-shadow 0.3s ease;
98
- }
99
-
100
- .main-card:hover {
101
- box-shadow: 0 8px 32px rgba(139, 92, 246, 0.12), 0 2px 6px rgba(0, 0, 0, 0.04);
102
- }
103
-
104
- /* ===== API Key Section ===== */
105
- .api-key-row {
106
- display: flex;
107
- align-items: center;
108
- justify-content: space-between;
109
- margin-bottom: 12px;
110
- }
111
-
112
- .api-key-label {
113
- display: flex;
114
- align-items: center;
115
- gap: 10px;
116
- color: #1e293b;
117
- font-weight: 700;
118
- font-size: 1rem;
119
- }
120
-
121
- .get-key-btn {
122
- padding: 10px 20px;
123
- background: linear-gradient(135deg, #8b5cf6, #7c3aed);
124
- border: none;
125
- border-radius: 10px;
126
- color: #fff !important;
127
- text-decoration: none;
128
- font-weight: 600;
129
- font-size: 0.85rem;
130
- transition: all 0.25s ease;
131
- box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
132
- }
133
-
134
- .get-key-btn:hover {
135
- transform: translateY(-2px);
136
- box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
137
- color: #fff;
138
- }
139
-
140
- /* ===== Section Title ===== */
141
- .section-title {
142
- color: #1e293b;
143
- font-weight: 700;
144
- font-size: 1rem;
145
- display: flex;
146
- align-items: center;
147
- gap: 10px;
148
- margin-bottom: 16px;
149
- }
150
-
151
- /* ===== Image Upload Styling ===== */
152
- .upload-area {
153
- border: 2px dashed rgba(139, 92, 246, 0.3) !important;
154
- border-radius: 16px !important;
155
- background: linear-gradient(145deg, #faf5ff 0%, #f5f3ff 100%) !important;
156
- transition: all 0.3s ease !important;
157
- min-height: 220px !important;
158
- }
159
-
160
- .upload-area:hover {
161
- border-color: rgba(139, 92, 246, 0.5) !important;
162
- background: linear-gradient(145deg, #f5f3ff 0%, #ede9fe 100%) !important;
163
- }
164
-
165
- /* ===== Result Area ===== */
166
- .result-area {
167
- border: 2px solid rgba(139, 92, 246, 0.15) !important;
168
- border-radius: 16px !important;
169
- background: #fafafa !important;
170
- min-height: 220px !important;
171
- }
172
-
173
- /* ===== Radio Button Styling ===== */
174
- .aspect-ratio-group {
175
- margin-top: 16px;
176
- }
177
-
178
- .aspect-ratio-group .wrap {
179
- gap: 8px !important;
180
- }
181
-
182
- .aspect-ratio-group label {
183
- padding: 10px 16px !important;
184
- border-radius: 10px !important;
185
- border: 2px solid #e2e8f0 !important;
186
- background: #fff !important;
187
- font-weight: 600 !important;
188
- font-size: 0.85rem !important;
189
- color: #64748b !important;
190
- transition: all 0.2s ease !important;
191
- cursor: pointer !important;
192
- }
193
-
194
- .aspect-ratio-group label:hover {
195
- border-color: #a78bfa !important;
196
- color: #7c3aed !important;
197
- }
198
-
199
- .aspect-ratio-group label.selected {
200
- background: linear-gradient(135deg, #8b5cf6, #7c3aed) !important;
201
- border-color: transparent !important;
202
- color: #fff !important;
203
- }
204
-
205
- /* ===== Button Styling ===== */
206
- .primary-btn {
207
- width: 100%;
208
- margin-top: 20px !important;
209
- background: linear-gradient(135deg, #8b5cf6, #7c3aed) !important;
210
- border: none !important;
211
- color: #fff !important;
212
- font-weight: 700 !important;
213
- font-size: 1rem !important;
214
- padding: 14px 28px !important;
215
- border-radius: 12px !important;
216
- box-shadow: 0 4px 16px rgba(139, 92, 246, 0.35) !important;
217
- transition: all 0.25s ease !important;
218
- cursor: pointer !important;
219
- }
220
-
221
- .primary-btn:hover {
222
- transform: translateY(-2px) !important;
223
- box-shadow: 0 8px 24px rgba(139, 92, 246, 0.45) !important;
224
- }
225
-
226
- .primary-btn:active {
227
- transform: translateY(0) !important;
228
- }
229
-
230
- /* ===== CTA Section ===== */
231
- .cta-container {
232
- text-align: center;
233
- padding: 44px 32px;
234
- background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 50%, #6d28d9 100%);
235
- border-radius: 20px;
236
- margin-top: 8px;
237
- box-shadow: 0 8px 32px rgba(139, 92, 246, 0.35);
238
- position: relative;
239
- overflow: hidden;
240
- }
241
-
242
- .cta-container::before {
243
- content: '';
244
- position: absolute;
245
- top: -50%;
246
- right: -50%;
247
- width: 100%;
248
- height: 100%;
249
- background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
250
- pointer-events: none;
251
- }
252
-
253
- .cta-title {
254
- color: #fff;
255
- font-size: 1.5rem;
256
- font-weight: 800;
257
- margin: 0 0 8px 0;
258
- position: relative;
259
- }
260
-
261
- .cta-desc {
262
- color: rgba(255, 255, 255, 0.9);
263
- font-size: 1rem;
264
- margin: 0 0 24px 0;
265
- position: relative;
266
- }
267
-
268
- .cta-btn {
269
- display: inline-block;
270
- padding: 14px 36px;
271
- background: #fff;
272
- border-radius: 12px;
273
- color: #7c3aed !important;
274
- text-decoration: none;
275
- font-weight: 700;
276
- font-size: 1rem;
277
- transition: all 0.25s ease;
278
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
279
- position: relative;
280
- }
281
-
282
- .cta-btn:hover {
283
- transform: translateY(-3px);
284
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
285
- color: #6d28d9 !important;
286
- }
287
-
288
- /* ===== Hide Elements ===== */
289
- footer { display: none !important; }
290
-
291
- .hide-label > .label-wrap {
292
- display: none !important;
293
- }
294
-
295
- .hide-label span.svelte-1gfkn6j {
296
- display: none !important;
297
- }
298
-
299
- /* ===== Input Styling ===== */
300
- .gradio-container input[type="password"],
301
- .gradio-container input[type="text"] {
302
- border: 2px solid #e2e8f0 !important;
303
- border-radius: 12px !important;
304
- padding: 14px 16px !important;
305
- font-size: 0.95rem !important;
306
- transition: all 0.2s ease !important;
307
- }
308
-
309
- .gradio-container input[type="password"]:focus,
310
- .gradio-container input[type="text"]:focus {
311
- border-color: #8b5cf6 !important;
312
- box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15) !important;
313
- outline: none !important;
314
- }
315
-
316
- /* ===== Divider ===== */
317
- .divider {
318
- width: 1px;
319
- background: linear-gradient(180deg, transparent 0%, #e2e8f0 20%, #e2e8f0 80%, transparent 100%);
320
- margin: 0 24px;
321
- align-self: stretch;
322
- }
323
- """
324
-
325
-
326
- # ============ API Functions ============
327
- def upload_image(api_key: str, file_path: str) -> str:
328
- headers = {"Authorization": f"Bearer {api_key.strip()}"}
329
- with open(file_path, "rb") as f:
330
- resp = requests.post(UPLOAD_ENDPOINT, headers=headers, files={"file": f}, timeout=60)
331
- if resp.status_code == 401:
332
- raise Exception("Invalid API Key")
333
- elif resp.status_code >= 400:
334
- raise Exception(f"Upload failed: {resp.status_code}")
335
- data = resp.json()
336
- if data.get("code") != 200:
337
- raise Exception(data.get("message", "Upload failed"))
338
- return data.get("data", {}).get("download_url")
339
-
340
-
341
- def call_api(api_key: str, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
342
- headers = {"Authorization": f"Bearer {api_key.strip()}", "Content-Type": "application/json"}
343
- resp = requests.post(f"{WAVESPEED_API_BASE}/{endpoint}", json=payload, headers=headers, timeout=30)
344
- if resp.status_code == 401:
345
- raise Exception("Invalid API Key")
346
- elif resp.status_code == 429:
347
- raise Exception("Quota exceeded")
348
- elif resp.status_code >= 400:
349
- raise Exception(f"API error: {resp.status_code}")
350
- data = resp.json()
351
- if data.get("code") != 200:
352
- raise Exception(data.get("message", "Unknown error"))
353
- return data.get("data", {})
354
-
355
-
356
- def poll_result(api_key: str, request_id: str) -> Dict[str, Any]:
357
- headers = {"Authorization": f"Bearer {api_key.strip()}"}
358
- url = f"{WAVESPEED_API_BASE}/predictions/{request_id}/result"
359
- start_time = time.time()
360
- while time.time() - start_time < POLL_MAX_SECONDS:
361
- resp = requests.get(url, headers=headers, timeout=30)
362
- if resp.status_code >= 400:
363
- raise Exception("Failed to get result")
364
- result = resp.json().get("data", {})
365
- status = result.get("status", "")
366
- if status == "completed":
367
- return result
368
- elif status == "failed":
369
- raise Exception("Generation failed")
370
- time.sleep(POLL_INTERVAL)
371
- raise Exception("Timeout")
372
-
373
-
374
- def expand_image(api_key: str, image_path: str, aspect_ratio: str):
375
- if not api_key or not api_key.strip():
376
- gr.Warning("Please enter your API Key")
377
- return None
378
- if not image_path:
379
- gr.Warning("Please upload an image")
380
- return None
381
-
382
- try:
383
- gr.Info("Uploading image...")
384
- image_url = upload_image(api_key, image_path)
385
-
386
- gr.Info("AI is expanding your image...")
387
- payload = {
388
- "image": image_url,
389
- "aspect_ratio": aspect_ratio,
390
- "enable_base64_output": False,
391
- "enable_sync_mode": False,
392
- }
393
- result = call_api(api_key, MODEL_ENDPOINT, payload)
394
- request_id = result.get("id")
395
- if not request_id:
396
- gr.Warning("Failed to start")
397
- return None
398
-
399
- final_result = poll_result(api_key, request_id)
400
- outputs = final_result.get("outputs", [])
401
- if outputs:
402
- gr.Info("Done!")
403
- return outputs[0]
404
- return None
405
-
406
- except Exception as e:
407
- error_msg = str(e).lower()
408
- if "invalid" in error_msg or "401" in error_msg:
409
- gr.Warning("Invalid API Key")
410
- elif "quota" in error_msg or "429" in error_msg:
411
- gr.Warning("Quota exceeded")
412
- elif "timeout" in error_msg:
413
- gr.Warning("Timeout - please try again")
414
- else:
415
- gr.Warning(str(e))
416
- return None
417
-
418
-
419
- # ============ Gradio UI ============
420
- with gr.Blocks(css=CUSTOM_CSS, title="Image Expand - Bria AI x WaveSpeed") as demo:
421
-
422
- # Hero Section
423
- gr.HTML("""
424
- <div class="hero-container">
425
- <div class="hero-badge">BRIA AI x WAVESPEED</div>
426
- <h1 class="hero-title">Image Expand</h1>
427
- <p class="hero-desc">
428
- Transform your images with AI-powered expansion. Intelligently extend any image to your desired aspect ratio.
429
- </p>
430
- <div class="hero-badges">
431
- <span>
432
- <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>
433
- Fast Processing
434
- </span>
435
- <span>
436
- <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>
437
- High Quality Output
438
- </span>
439
- <span>
440
- <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>
441
- Easy to Use
442
- </span>
443
- </div>
444
- </div>
445
- """)
446
-
447
- # API Key Card
448
- with gr.Group(elem_classes="main-card"):
449
- gr.HTML("""
450
- <div class="api-key-row">
451
- <span class="api-key-label">
452
- <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>
453
- API Key
454
- </span>
455
- <a href="https://wavespeed.ai/accesskey" target="_blank" class="get-key-btn">Get API Key</a>
456
- </div>
457
- """)
458
- api_key_input = gr.Textbox(
459
- placeholder="Enter your WaveSpeed API key",
460
- type="password",
461
- show_label=False
462
- )
463
-
464
- # Main Content Card
465
- with gr.Group(elem_classes="main-card"):
466
- with gr.Row():
467
- # Left Column - Input
468
- with gr.Column(scale=1):
469
- gr.HTML("""
470
- <div class="section-title">
471
- <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>
472
- Upload Image
473
- </div>
474
- """)
475
- image_input = gr.Image(
476
- label="",
477
- type="filepath",
478
-
479
- elem_classes=["hide-label", "upload-area"]
480
- )
481
- aspect_ratio_input = gr.Radio(
482
- label="Aspect Ratio",
483
- choices=ASPECT_RATIOS,
484
- value="16:9",
485
- elem_classes="aspect-ratio-group"
486
- )
487
- submit_btn = gr.Button("Expand Image", variant="primary", elem_classes="primary-btn")
488
-
489
- # Right Column - Output
490
- with gr.Column(scale=1):
491
- gr.HTML("""
492
- <div class="section-title">
493
- <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>
494
- Result
495
- </div>
496
- """)
497
- output_image = gr.Image(
498
- label="",
499
- type="filepath",
500
- interactive=False,
501
- elem_classes=["hide-label", "result-area"]
502
- )
503
-
504
- # CTA Section
505
- gr.HTML("""
506
- <div class="cta-container">
507
- <h3 class="cta-title">Want More Features?</h3>
508
- <p class="cta-desc">Higher resolutions, batch processing, and 700+ AI models</p>
509
- <a href="https://wavespeed.ai/models" target="_blank" class="cta-btn">
510
- Explore WaveSpeed.ai
511
- </a>
512
- </div>
513
- """)
514
-
515
- # Event binding
516
- submit_btn.click(
517
- fn=expand_image,
518
- inputs=[api_key_input, image_input, aspect_ratio_input],
519
- outputs=output_image,
520
- )
521
-
522
-
523
- if __name__ == "__main__":
524
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
1
+ """Image Expand — 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": "Image Expand",
18
+ "model": "bria/expand",
19
+ "output": "image",
20
+ "tagline": "Extend an image beyond its original frame (outpainting).",
21
+ "fields": [
22
+ {
23
+ "kind": "image",
24
+ "key": "image",
25
+ "label": "Input image"
26
+ },
27
+ {
28
+ "kind": "choice",
29
+ "key": "aspect_ratio",
30
+ "label": "Target aspect ratio",
31
+ "choices": [
32
+ "16:9",
33
+ "9:16",
34
+ "1:1",
35
+ "4:3",
36
+ "3:4"
37
+ ],
38
+ "default": "16:9"
39
+ }
40
+ ],
41
+ "extra": {
42
+ "enable_base64_output": False,
43
+ "enable_sync_mode": False
44
+ }
45
+ }
46
+
47
+ SPEC["css"] = Path(__file__).with_name("style.css").read_text(encoding="utf-8")
48
+
49
+ if __name__ == "__main__":
50
+ spaceui.launch(spaceui.build(SPEC), SPEC["css"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,9 +1,2 @@
1
- gradio>=6.0,<7.0
2
- gradio_client>=2.0,<3.0
3
- huggingface_hub>=1.0,<2.0
4
- jinja2>=3.1,<4.0
5
- starlette>=1.0,<2.0
6
- fastapi>=0.115,<1.0
7
- pydub>=0.25
8
- requests>=2.28.0
9
- uvicorn>=0.30
 
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)