Haorui Jiang commited on
Commit
4b9cfe4
·
0 Parent(s):

Deploy from Space Generator

Browse files
Files changed (3) hide show
  1. Dockerfile +14 -0
  2. README.md +10 -0
  3. app.py +507 -0
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"]
README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Hailuo 2.3 Fast
3
+ emoji: 🎬
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ short_description: Fast image-to-video generation with MiniMax Hailuo 2.3.
10
+ ---
app.py ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hailuo 2.3 Fast - 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 = "minimax/hailuo-2.3/fast"
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, prompt: 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 prompt or not prompt.strip():
361
+ gr.Warning("Please enter a prompt")
362
+ return None
363
+
364
+ try:
365
+ gr.Info("Uploading image...")
366
+ image_url = upload_image(api_key, image_path)
367
+
368
+ gr.Info("Generating video...")
369
+ payload = {
370
+ "image": image_url,
371
+ "prompt": prompt.strip(),
372
+ "duration": 6,
373
+ "enable_prompt_expansion": True,
374
+ "go_fast": True,
375
+ }
376
+ result = call_api(api_key, MODEL_ENDPOINT, payload)
377
+ request_id = result.get("id")
378
+ if not request_id:
379
+ gr.Warning("Failed to start")
380
+ return None
381
+
382
+ final_result = poll_result(api_key, request_id)
383
+ outputs = final_result.get("outputs", [])
384
+ if outputs:
385
+ gr.Info("Downloading video...")
386
+ video_path = download_video(outputs[0])
387
+ if video_path:
388
+ gr.Info("Done!")
389
+ return video_path
390
+ return None
391
+
392
+ except Exception as e:
393
+ error_msg = str(e).lower()
394
+ if "invalid" in error_msg or "401" in error_msg:
395
+ gr.Warning("Invalid API Key")
396
+ elif "quota" in error_msg or "429" in error_msg:
397
+ gr.Warning("Quota exceeded")
398
+ elif "timeout" in error_msg:
399
+ gr.Warning("Timeout - please try again")
400
+ else:
401
+ gr.Warning(str(e))
402
+ return None
403
+
404
+
405
+ # ============ Gradio UI ============
406
+ with gr.Blocks(css=CUSTOM_CSS, title="Hailuo 2.3 Fast - WaveSpeed") as demo:
407
+
408
+ # Hero Section
409
+ gr.HTML("""
410
+ <div class="hero-container">
411
+ <div class="hero-badge">MINIMAX x WAVESPEED</div>
412
+ <h1 class="hero-title">Hailuo 2.3 Fast</h1>
413
+ <p class="hero-desc">Fast image-to-video generation with MiniMax Hailuo 2.3.</p>
414
+ <div class="hero-badges">
415
+ <span>
416
+ <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>
417
+ Fast Processing
418
+ </span>
419
+ <span>
420
+ <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>
421
+ High Quality
422
+ </span>
423
+ <span>
424
+ <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>
425
+ Easy to Use
426
+ </span>
427
+ </div>
428
+ </div>
429
+ """)
430
+
431
+
432
+
433
+ # API Key Card
434
+ with gr.Group(elem_classes="main-card"):
435
+ gr.HTML("""
436
+ <div class="api-key-row">
437
+ <span class="api-key-label">
438
+ <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>
439
+ API Key
440
+ </span>
441
+ <a href="https://wavespeed.ai/accesskey" target="_blank" class="get-key-btn">Get API Key</a>
442
+ </div>
443
+ """)
444
+ api_key_input = gr.Textbox(
445
+ placeholder="Enter your WaveSpeed API key",
446
+ type="password",
447
+ show_label=False
448
+ )
449
+
450
+ # Main Content Card
451
+ with gr.Group(elem_classes="main-card"):
452
+ with gr.Row():
453
+ # Left Column - Input
454
+ with gr.Column(scale=1):
455
+ gr.HTML("""
456
+ <div class="section-title">
457
+ <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>
458
+ Upload Image
459
+ </div>
460
+ """)
461
+ image_input = gr.Image(
462
+ label="Upload Image",
463
+ type="filepath",
464
+ source="upload",
465
+ elem_classes=["upload-area"]
466
+ )
467
+ prompt_input = gr.Textbox(
468
+ label="Prompt",
469
+ placeholder="Describe what you want...",
470
+ lines=3
471
+ )
472
+ submit_btn = gr.Button("Process", variant="primary", elem_classes="primary-btn")
473
+
474
+ # Right Column - Output
475
+ with gr.Column(scale=1):
476
+ gr.HTML("""
477
+ <div class="section-title">
478
+ <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>
479
+ Result
480
+ </div>
481
+ """)
482
+ output_video = gr.Video(
483
+ label="",
484
+ interactive=False
485
+ )
486
+
487
+ # CTA Section
488
+ gr.HTML("""
489
+ <div class="cta-container">
490
+ <h3 class="cta-title">Want More Features?</h3>
491
+ <p class="cta-desc">Higher resolutions, batch processing, and 700+ AI models</p>
492
+ <a href="https://wavespeed.ai/models" target="_blank" class="cta-btn">
493
+ Explore WaveSpeed.ai
494
+ </a>
495
+ </div>
496
+ """)
497
+
498
+ # Event binding
499
+ submit_btn.click(
500
+ fn=process,
501
+ inputs=[api_key_input, image_input, prompt_input],
502
+ outputs=output_video,
503
+ )
504
+
505
+
506
+ if __name__ == "__main__":
507
+ demo.launch(server_name="0.0.0.0", server_port=7860)