chengzeyi commited on
Commit
2d02944
·
1 Parent(s): 2eef799

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