xiaoxishui commited on
Commit
4f722b6
·
verified ·
1 Parent(s): 1dfa24a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1109 -8
app.py CHANGED
@@ -1,12 +1,1113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- # 创建一个简单的界面
4
- with gr.Blocks() as demo:
5
- gr.Markdown("# AI视频生成应用")
6
- input_text = gr.Textbox(label="输入描述")
7
- output_video = gr.Video(label="生成的视频")
8
- gr.Button("生成")
9
 
10
- # 启动应用
11
  if __name__ == "__main__":
12
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 视频生成 Gradio 前端界面 (远程API版)
3
+ 支持 Seedance 和 Sora2 两种模型提供者
4
+ 支持文生视频和图生视频两种模式
5
+
6
+ 调用远程 API 服务,无需本地启动 API 服务器
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import time
12
+ import tempfile
13
+ import httpx
14
+ import ssl
15
+ import base64
16
  import gradio as gr
17
+ from pathlib import Path
18
+ from dotenv import load_dotenv
19
+ from qwen3vl import analyze_video
20
+
21
+ load_dotenv()
22
+
23
+ # ========== Seedance API 配置 ==========
24
+ SEEDANCE_API_BASE_URL = os.getenv("SEEDANCE_API_BASE_URL", "https://seedanceapi.duckcloud.fun")
25
+ SEEDANCE_AUTH_TOKEN = os.getenv("SEEDANCE_AUTH_TOKEN", "sk-doubao-video-2025")
26
+
27
+ # ========== Sora2 API 配置 ==========
28
+ SORA2_API_BASE_URL = os.getenv("SORA2_API_BASE_URL", "https://api.jxincm.cn")
29
+ SORA2_API_KEY = os.getenv("SORA2_API_KEY", "")
30
+ SORA2_ENABLED = os.getenv("SORA2_ENABLED", "true").lower() == "true"
31
+
32
+ # ========== Sora2免费 API 配置 ==========
33
+ SORA2FREE_API_BASE_URL = os.getenv("SORA2FREE_API_BASE_URL", "https://rendersora2api.duckcloud.fun")
34
+ SORA2FREE_API_KEY = os.getenv("SORA2FREE_API_KEY", "")
35
+
36
+ # 兼容旧配置
37
+ if not SEEDANCE_AUTH_TOKEN:
38
+ SEEDANCE_AUTH_TOKEN = os.getenv("AUTH_TOKEN", "sk-doubao-video-2025")
39
+ if not SEEDANCE_API_BASE_URL:
40
+ SEEDANCE_API_BASE_URL = os.getenv("API_BASE_URL", "https://seedanceapi.duckcloud.fun")
41
+
42
+
43
+ def get_seedance_auth_headers() -> dict:
44
+ """获取 Seedance 包含鉴权信息的请求头"""
45
+ headers = {}
46
+ if SEEDANCE_AUTH_TOKEN:
47
+ headers["Authorization"] = f"Bearer {SEEDANCE_AUTH_TOKEN}"
48
+ return headers
49
+
50
+
51
+ def get_sora2_auth_headers() -> dict:
52
+ """获取 Sora2 包含鉴权信息的请求头"""
53
+ headers = {
54
+ "Content-Type": "application/json",
55
+ "Accept": "application/json"
56
+ }
57
+ if SORA2_API_KEY:
58
+ headers["Authorization"] = f"Bearer {SORA2_API_KEY}"
59
+ return headers
60
+
61
+
62
+ def _get_content_type(suffix: str) -> str:
63
+ """获取文件MIME类型"""
64
+ content_types = {
65
+ ".png": "image/png",
66
+ ".jpg": "image/jpeg",
67
+ ".jpeg": "image/jpeg",
68
+ ".gif": "image/gif",
69
+ ".webp": "image/webp",
70
+ ".bmp": "image/bmp"
71
+ }
72
+ return content_types.get(suffix.lower(), "application/octet-stream")
73
+
74
+
75
+ def request_with_retry(method, url, max_retries=3, timeout=60.0, **kwargs):
76
+ """带重试机制的请求函数,专门处理 SSL、协议错误和重定向"""
77
+ last_error = None
78
+
79
+ # 尝试多种配置组合
80
+ configs = [
81
+ {"http2": False, "verify": True}, # 标准配置
82
+ {"http2": False, "verify": False}, # 禁用验证 (应对证书问题)
83
+ ]
84
+
85
+ # 规范化 URL,确保没有重复的斜杠
86
+ if "://" in url:
87
+ parts = url.split("://", 1)
88
+ url = f"{parts[0]}://{parts[1].replace('//', '/')}"
89
+
90
+ for config in configs:
91
+ for attempt in range(max_retries):
92
+ try:
93
+ with httpx.Client(
94
+ timeout=timeout,
95
+ http2=config["http2"],
96
+ verify=config["verify"],
97
+ follow_redirects=True
98
+ ) as client:
99
+ response = client.request(method, url, **kwargs)
100
+
101
+ # 检查重定向 (httpx follow_redirects=True 会自动处理,但 POST 可能变 GET)
102
+ # 如果返回 302 且我们想要 POST,我们需要确认是否变成了 GET
103
+ if response.status_code == 302 and method == "POST":
104
+ print(f"[API] POST 请求被重定向 (302),请检查 API 地址是否正确: {url}")
105
+
106
+ # 如果响应状态码不是 2xx,记录更多信息
107
+ if response.status_code >= 400:
108
+ print(f"[API] 请求失败: HTTP {response.status_code} - {response.text[:200]}")
109
+ response.raise_for_status()
110
+
111
+ return response.json()
112
+ except (httpx.HTTPError, ssl.SSLError, Exception) as e:
113
+ last_error = e
114
+ # 如果是 SSL 错误且当前验证为 True,则跳出重试循环,尝试下一个配置
115
+ if isinstance(e, (ssl.SSLError, httpx.ConnectError)) and config["verify"]:
116
+ print(f"[API] SSL/连接错误,将尝试备选配置: {str(e)}")
117
+ break
118
+
119
+ # 处理 302 特殊情况:如果 response 存在且是 302
120
+ if hasattr(e, 'response') and e.response is not None and e.response.status_code == 302:
121
+ print(f"[API] 捕获到 302 重定向错误: {e.response.headers.get('Location')}")
122
+
123
+ if attempt < max_retries - 1:
124
+ wait_time = (attempt + 1) * 2
125
+ print(f"[API] 请求失败 ({type(e).__name__}: {str(e)}),正在进行第 {attempt + 2} 次重试 (等待 {wait_time}s)...")
126
+ time.sleep(wait_time)
127
+ else:
128
+ print(f"[API] 第 {attempt + 1} 次尝试失败,已达到最大重试次数")
129
+
130
+ # 如果所有尝试都失败了
131
+ raise last_error
132
+
133
+
134
+ # ========== 模型提供者选项 ==========
135
+ PROVIDER_OPTIONS = ["seedance", "sora2", "sora2free"]
136
+
137
+ # ========== Seedance 模型选项 ==========
138
+ SEEDANCE_MODEL_OPTIONS = [
139
+ ("seedance-1-5-pro-251215 (最新)", "seedance-1-5-pro-251215"),
140
+ ("seedance-1-0-pro-fast (快速)", "seedance-1-0-pro-fast"),
141
+ ]
142
+
143
+ # Seedance 时长选项
144
+ SEEDANCE_DURATION_OPTIONS = [4, 5, 8, 12]
145
+
146
+ # Seedance 比例选项
147
+ SEEDANCE_RATIO_OPTIONS = [
148
+ ("21:9 (超宽银幕)", "21:9"),
149
+ ("16:9 (横屏·默认)", "16:9"),
150
+ ("4:3 (经典比例)", "4:3"),
151
+ ("1:1 (正方形)", "1:1"),
152
+ ("3:4 (竖屏偏方)", "3:4"),
153
+ ("9:16 (竖屏·抖音/Shorts)", "9:16"),
154
+ ]
155
+
156
+ # ========== Sora2 模型选项 ==========
157
+ SORA2_MODEL_OPTIONS = [
158
+ ("sora-2", "sora-2"),
159
+ ]
160
+
161
+ # Sora2 时长选项
162
+ SORA2_DURATION_OPTIONS = [10, 15]
163
+
164
+ # Sora2 比例选项 (orientation)
165
+ SORA2_RATIO_OPTIONS = [
166
+ ("portrait (竖屏)", "portrait"),
167
+ ("landscape (横屏)", "landscape"),
168
+ ]
169
+
170
+ # ========== Sora2免费 模型选项 ==========
171
+ SORA2FREE_MODEL_OPTIONS = [
172
+ ("sora2-landscape-10s (横屏10秒)", "sora2-landscape-10s"),
173
+ ("sora2-landscape-15s (横屏15秒)", "sora2-landscape-15s"),
174
+ ("sora2-portrait-10s (竖屏10秒)", "sora2-portrait-10s"),
175
+ ("sora2-portrait-15s (竖屏15秒)", "sora2-portrait-15s"),
176
+ ]
177
+
178
+
179
+ def upload_image(file_path: str) -> dict:
180
+ """
181
+ 上传图片到 Seedance 远程API服务
182
+ """
183
+ path = Path(file_path)
184
+ if not path.exists():
185
+ return {"success": False, "message": f"图片文件不存在: {file_path}"}
186
+
187
+ try:
188
+ with open(path, "rb") as f:
189
+ files = {"file": (path.name, f, _get_content_type(path.suffix))}
190
+ return request_with_retry(
191
+ "POST",
192
+ f"{SEEDANCE_API_BASE_URL}/api/upload/",
193
+ files=files,
194
+ headers=get_seedance_auth_headers(),
195
+ timeout=60.0
196
+ )
197
+ except Exception as e:
198
+ return {"success": False, "message": f"上传失败: {str(e)}"}
199
+
200
+
201
+ def create_seedance_video(prompt: str, model: str, duration: int, ratio: str, image_url: str = None) -> dict:
202
+ """
203
+ 创建 Seedance 视频任务
204
+ """
205
+ payload = {
206
+ "model": model,
207
+ "prompt": prompt,
208
+ "duration": duration,
209
+ "ratio": ratio
210
+ }
211
+ if image_url:
212
+ payload["image"] = image_url
213
+
214
+ try:
215
+ return request_with_retry(
216
+ "POST",
217
+ f"{SEEDANCE_API_BASE_URL}/api/video/create/",
218
+ json=payload,
219
+ headers=get_seedance_auth_headers(),
220
+ timeout=120.0
221
+ )
222
+ except Exception as e:
223
+ return {"success": False, "message": f"创建视频失败: {str(e)}"}
224
+
225
+
226
+ def get_seedance_videos() -> list:
227
+ """
228
+ 获取 Seedance 视频列表
229
+ """
230
+ try:
231
+ result = request_with_retry(
232
+ "GET",
233
+ f"{SEEDANCE_API_BASE_URL}/api/videos/",
234
+ headers=get_seedance_auth_headers(),
235
+ timeout=30.0
236
+ )
237
+ if result.get("success"):
238
+ return result.get("data", [])
239
+ return []
240
+ except Exception as e:
241
+ print(f"[API] 获取视频列表失败: {e}")
242
+ return []
243
+
244
+
245
+ def find_seedance_video_by_task_id(task_id: str) -> dict:
246
+ """
247
+ 根据task_id从 Seedance 视频列表中查找视频
248
+ 参考 client.py 中的 find_video_by_task_id 方法
249
+ """
250
+ videos = get_seedance_videos()
251
+ if not videos:
252
+ return None
253
+
254
+ # 提取核心task_id(去掉 ::model 后缀)
255
+ core_task_id = task_id.split("::")[0] if "::" in task_id else task_id
256
+
257
+ for video in videos:
258
+ vid_task_id = video.get("taskId") or video.get("task_id") or ""
259
+ vid_id = str(video.get("id", ""))
260
+
261
+ # 精确匹配
262
+ if task_id == vid_task_id or task_id == vid_id:
263
+ return video
264
+
265
+ # 核心ID匹配
266
+ if core_task_id == vid_task_id or core_task_id == vid_id:
267
+ return video
268
+
269
+ # 部分匹配
270
+ if vid_task_id and core_task_id in vid_task_id:
271
+ return video
272
+ if core_task_id and vid_task_id and vid_task_id in core_task_id:
273
+ return video
274
+
275
+ return None
276
+
277
+
278
+ # ========== Sora2 API 函数 ==========
279
+
280
+ def upload_image_to_url(file_path: str) -> str:
281
+ """
282
+ 将本地图片转换为 base64 data URL 或上传到图床
283
+ Sora2 需要图片 URL,这里使用 base64 data URL
284
+ """
285
+ path = Path(file_path)
286
+ if not path.exists():
287
+ return None
288
+
289
+ try:
290
+ with open(path, "rb") as f:
291
+ image_data = f.read()
292
+ base64_data = base64.b64encode(image_data).decode('utf-8')
293
+ content_type = _get_content_type(path.suffix)
294
+ return f"data:{content_type};base64,{base64_data}"
295
+ except Exception as e:
296
+ print(f"[Sora2] 图���转换失败: {e}")
297
+ return None
298
+
299
+
300
+ def create_sora2_video(prompt: str, model: str, duration: int, orientation: str, image_urls: list = None) -> dict:
301
+ """
302
+ 创建 Sora2 视频任务
303
+
304
+ Args:
305
+ prompt: 视频描述提示词
306
+ model: 模型名称 (sora-2)
307
+ duration: 视频时长 (10 或 15 秒)
308
+ orientation: 屏幕方向 (portrait 或 landscape)
309
+ image_urls: 图片URL列表 (图生视频模式)
310
+
311
+ Returns:
312
+ API 响应字典
313
+ """
314
+ payload = {
315
+ "model": model,
316
+ "prompt": prompt,
317
+ "duration": duration,
318
+ "orientation": orientation,
319
+ "size": "large",
320
+ "watermark": False,
321
+ "private": True,
322
+ "images": image_urls if image_urls else []
323
+ }
324
+
325
+ try:
326
+ print(f"[Sora2] 发送请求: {SORA2_API_BASE_URL}/v1/video/create")
327
+ print(f"[Sora2] 参数: model={model}, duration={duration}, orientation={orientation}")
328
+
329
+ result = request_with_retry(
330
+ "POST",
331
+ f"{SORA2_API_BASE_URL}/v1/video/create",
332
+ json=payload,
333
+ headers=get_sora2_auth_headers(),
334
+ timeout=120.0
335
+ )
336
+
337
+ # Sora2 返回格式转换为统一格式
338
+ if result.get("id"):
339
+ return {
340
+ "success": True,
341
+ "data": {
342
+ "task": {
343
+ "task_id": result.get("id")
344
+ },
345
+ "status": result.get("status"),
346
+ "model": result.get("model")
347
+ }
348
+ }
349
+ return {"success": False, "message": "创建任务失败,未获取到任务ID"}
350
+
351
+ except Exception as e:
352
+ return {"success": False, "message": f"创建Sora2视频失败: {str(e)}"}
353
+
354
+
355
+ def query_sora2_video(task_id: str) -> dict:
356
+ """
357
+ 查询 Sora2 视频任务状态
358
+
359
+ Args:
360
+ task_id: 任务ID
361
+
362
+ Returns:
363
+ 任务状态字典
364
+ """
365
+ try:
366
+ result = request_with_retry(
367
+ "GET",
368
+ f"{SORA2_API_BASE_URL}/v1/video/query?id={task_id}",
369
+ headers=get_sora2_auth_headers(),
370
+ timeout=30.0
371
+ )
372
+ return result
373
+ except Exception as e:
374
+ print(f"[Sora2] 查询任务失败: {e}")
375
+ return None
376
+
377
+
378
+ # ========== Sora2免费 API 函数 ==========
379
+
380
+ def get_sora2free_auth_headers() -> dict:
381
+ """获取 Sora2免费 包含鉴权信息的请求头"""
382
+ headers = {
383
+ "Content-Type": "application/json",
384
+ "Authorization": f"Bearer {SORA2FREE_API_KEY}"
385
+ }
386
+ return headers
387
+
388
+
389
+ def create_sora2free_video(prompt: str, model: str) -> dict:
390
+ """
391
+ 创建 Sora2免费 视频任务 (SSE 流式响应)
392
+
393
+ Args:
394
+ prompt: 视频描述提示词
395
+ model: 模型名称
396
+
397
+ Returns:
398
+ 包含视频URL的字典
399
+ """
400
+ if not SORA2FREE_API_KEY:
401
+ return {"success": False, "message": "Sora2免费 API Key 未配置"}
402
+
403
+ try:
404
+ import re
405
+
406
+ payload = {
407
+ "model": model,
408
+ "messages": [
409
+ {
410
+ "role": "user",
411
+ "content": prompt
412
+ }
413
+ ],
414
+ "stream": True
415
+ }
416
+
417
+ url = f"{SORA2FREE_API_BASE_URL}/v1/chat/completions"
418
+
419
+ print(f"[Sora2免费] 发送请求: {url}")
420
+ print(f"[Sora2免费] 模型: {model}")
421
+
422
+ # 发送 SSE 请求
423
+ with httpx.Client(timeout=300.0) as client:
424
+ with client.stream(
425
+ "POST",
426
+ url,
427
+ json=payload,
428
+ headers=get_sora2free_auth_headers()
429
+ ) as response:
430
+ if response.status_code != 200:
431
+ error_text = response.text[:500] if response.text else "无响应内容"
432
+ return {"success": False, "message": f"请求失败: HTTP {response.status_code}\n{error_text}"}
433
+
434
+ # 解析 SSE 流,提取视频URL
435
+ video_url = None
436
+ full_content = ""
437
+
438
+ for line in response.iter_lines():
439
+ if line:
440
+ # SSE 格式: data: {...}
441
+ line = line.decode('utf-8') if isinstance(line, bytes) else line
442
+ if line.startswith('data: '):
443
+ data = line[6:] # 去掉 'data: '
444
+ if data == '[DONE]':
445
+ break
446
+ try:
447
+ import json as json_module
448
+ chunk = json_module.loads(data)
449
+ delta = chunk.get('choices', [{}])[0].get('delta', {})
450
+ content = delta.get('content', '')
451
+ if content:
452
+ full_content += content
453
+ # 从 HTML 格式提取视频URL
454
+ # 格式: ```html\n<video src='https://xxx.mp4' controls></video>\n```
455
+ video_match = re.search(r"src='(https?://[^']+\.mp4)'", content)
456
+ if video_match:
457
+ video_url = video_match.group(1)
458
+ print(f"[Sora2免费] ✅ 提取到视频URL: {video_url}")
459
+ except json_module.JSONDecodeError:
460
+ continue
461
+
462
+ if video_url:
463
+ return {"success": True, "video_url": video_url, "raw_content": full_content}
464
+ else:
465
+ return {"success": False, "message": "未在响应中提取到视频URL", "raw_content": full_content}
466
+
467
+ except httpx.TimeoutException:
468
+ return {"success": False, "message": "请求超时"}
469
+ except Exception as e:
470
+ return {"success": False, "message": f"请求失败: {str(e)}"}
471
+
472
+
473
+ def generate_sora2free_video(prompt: str, model: str):
474
+ """生成 Sora2免费 视频"""
475
+ if not prompt or not prompt.strip():
476
+ return None, "❌ 请输入视频描述提示词"
477
+
478
+ if not SORA2FREE_API_KEY:
479
+ return None, "❌ Sora2免费 API Key 未配置"
480
+
481
+ try:
482
+ print(f"[Sora2免费] 🎬 正在提交文生视频任务...")
483
+
484
+ create_result = create_sora2free_video(prompt, model)
485
+
486
+ if not create_result.get("success"):
487
+ return None, f"❌ {create_result.get('message', '未知错误')}"
488
+
489
+ video_url = create_result.get("video_url")
490
+ if video_url:
491
+ print(f"[Sora2免费] 📎 视频URL: {video_url}")
492
+ # 下载视频到本地
493
+ local_path = download_video_to_local(video_url, use_seedance_proxy=False)
494
+ if local_path:
495
+ return local_path, f"✅ Sora2免费视频生成成功!\n📎 视频URL: {video_url}\n💡 已下载到本地"
496
+ else:
497
+ return None, f"⚠️ 视频生成成功但下载失败\n📎 视频URL: {video_url}\n请复制链接手动下载"
498
+ else:
499
+ return None, f"⚠️ 未获取到视频URL"
500
+
501
+ except Exception as e:
502
+ return None, f"❌ 发生错误: {str(e)}"
503
+
504
+
505
+ def download_video_to_local(video_url: str, use_seedance_proxy: bool = True) -> str:
506
+ """
507
+ 下载视频到本地临时文件
508
+ 参考 client.py 中的 download_video 方法
509
+
510
+ 优先使用代理下载,解决国内网络无法直接访问外网视频URL的问题
511
+
512
+ Args:
513
+ video_url: 视频URL
514
+ use_seedance_proxy: 是否使用 Seedance 代理 (仅对 Seedance 视频有效)
515
+ """
516
+ if not video_url:
517
+ return None
518
+
519
+ try:
520
+ print(f"[Gradio] 📥 正在下载视频到本地...")
521
+
522
+ # 检查是否需要使用代理(外网视频域名)
523
+ proxy_domains = [
524
+ "ark-content-generation",
525
+ "tos-ap-southeast",
526
+ "volces.com"
527
+ ]
528
+ use_proxy = use_seedance_proxy and any(domain in video_url for domain in proxy_domains)
529
+
530
+ if use_proxy:
531
+ # 使用代理URL下载
532
+ download_url = f"{SEEDANCE_API_BASE_URL}/proxy/{video_url}"
533
+ print(f"[Gradio] 🔄 使用代理下载: {SEEDANCE_API_BASE_URL}/proxy/...")
534
+ else:
535
+ download_url = video_url
536
+
537
+ # 使用较长的超时时间,视频文件可能较大
538
+ try:
539
+ # 同样尝试多种配置
540
+ configs = [
541
+ {"http2": False, "verify": True},
542
+ {"http2": False, "verify": False},
543
+ ]
544
+
545
+ response = None
546
+ last_err = None
547
+
548
+ for config in configs:
549
+ try:
550
+ with httpx.Client(
551
+ timeout=300.0,
552
+ follow_redirects=True,
553
+ http2=config["http2"],
554
+ verify=config["verify"]
555
+ ) as client:
556
+ response = client.get(download_url)
557
+ response.raise_for_status()
558
+ break
559
+ except Exception as e:
560
+ last_err = e
561
+ if config["verify"]: continue
562
+ else: raise e
563
+
564
+ if response and response.status_code == 200:
565
+ # 获取文件扩展名
566
+ content_type = response.headers.get("content-type", "")
567
+ if "mp4" in content_type or video_url.endswith(".mp4"):
568
+ suffix = ".mp4"
569
+ elif "webm" in content_type or video_url.endswith(".webm"):
570
+ suffix = ".webm"
571
+ else:
572
+ suffix = ".mp4" # 默认mp4
573
+
574
+ # 创建临时文件
575
+ fd, temp_path = tempfile.mkstemp(suffix=suffix)
576
+ with os.fdopen(fd, 'wb') as f:
577
+ f.write(response.content)
578
+
579
+ file_size = len(response.content) / (1024 * 1024) # MB
580
+ print(f"[Gradio] ✅ 视频下载完成: {temp_path} ({file_size:.2f} MB)")
581
+ return temp_path
582
+ else:
583
+ print(f"[Gradio] ❌ 视频下载失败: HTTP {response.status_code}")
584
+ return None
585
+
586
+ except httpx.TimeoutException:
587
+ print(f"[Gradio] ❌ 视频下载超时")
588
+ return None
589
+ except Exception as e:
590
+ print(f"[Gradio] ❌ 视频下载失败: {e}")
591
+ return None
592
+
593
+ except Exception as e:
594
+ print(f"[Gradio] ❌ 视频下载过程发生异常: {e}")
595
+ return None
596
+
597
+
598
+ def generate_seedance_video(prompt: str, model: str, duration: int, ratio: str, image=None):
599
+ """生成 Seedance 视频 - 包含轮询等待逻辑"""
600
+ if not prompt or not prompt.strip():
601
+ return None, "❌ 请输入视频描述提示词"
602
+
603
+ # 确保提示词中的参数与 UI 选择一致,防止提示词自带的参数覆盖 UI 选择
604
+ # 移除可能存在的旧参数 (兼容 -duration=8 或 -ratio=16:9 格式)
605
+ prompt = re.sub(r'\s*-duration=\d+', '', prompt)
606
+ prompt = re.sub(r'\s*-ratio=[\d:]+', '', prompt)
607
+ # 追加当前 UI 选择的参数到提示词末尾
608
+ prompt = f"{prompt.strip()} -duration={duration} -ratio={ratio}"
609
+
610
+ max_wait_seconds = 600 # 最大等待10分钟
611
+ poll_interval = 10 # 每10秒轮询一次
612
+
613
+ try:
614
+ # 如果有图片,先上传
615
+ image_url = None
616
+ if image is not None:
617
+ print("[Seedance] 📤 正在上传图片...")
618
+ upload_result = upload_image(image)
619
+ if not upload_result.get("success"):
620
+ return None, f"❌ 图片上传失败: {upload_result.get('message', '未知错误')}"
621
+ image_url = upload_result.get("url")
622
+ if not image_url:
623
+ return None, "❌ 上传成功但未获取到图片URL"
624
+ print(f"[Seedance] ✅ 图片上传成功: {image_url}")
625
+
626
+ # 创建视频任务
627
+ mode = "图生视频" if image_url else "文生视频"
628
+ print(f"[Seedance] 🎬 正在提交{mode}任务到远程服务器...")
629
+
630
+ create_result = create_seedance_video(prompt, model, duration, ratio, image_url)
631
+
632
+ if not create_result.get("success"):
633
+ return None, f"❌ 创建任务失败: {create_result.get('message', '未知错误')}"
634
+
635
+ # 提取task_id
636
+ task_data = create_result.get("data", {})
637
+ task = task_data.get("task", {})
638
+ task_id = task.get("task_id") or task_data.get("taskId") or task_data.get("task_id") or task_data.get("id")
639
+
640
+ if not task_id:
641
+ return None, f"⚠️ 任务已提交({mode}),但无法获取任务ID,请稍后手动查询"
642
+
643
+ print(f"[Seedance] ✅ 任务创建成功! 任务ID: {task_id}")
644
+
645
+ # 轮询等待视频生成完成
646
+ start_time = time.time()
647
+ elapsed = 0
648
+ progress_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
649
+
650
+ while elapsed < max_wait_seconds:
651
+ # 查找视频
652
+ video = find_seedance_video_by_task_id(task_id)
653
+
654
+ if video:
655
+ status = (video.get("status") or "").lower()
656
+ video_url = video.get("url") or video.get("videoUrl") or video.get("video_url")
657
+
658
+ # 检查完成状态
659
+ if status in ["completed", "success", "done", "finished", "succeeded"]:
660
+ print(f"[Seedance] 🎉 视频生成完成!")
661
+ if video_url:
662
+ print(f"[Seedance] 📎 视频远程地址: {video_url}")
663
+ # 下载视频到本地,避免Gradio直接访问外网URL导致DNS解析失败
664
+ local_path = download_video_to_local(video_url, use_seedance_proxy=True)
665
+ if local_path:
666
+ return local_path, f"✅ 视频生成成功! ({mode})\n⏱️ 耗时: {int(elapsed)}秒\n🔗 远程服务: {SEEDANCE_API_BASE_URL}\n📎 视频URL: {video_url}\n💡 已通过代理下载到本地"
667
+ else:
668
+ # 下载失败时返回代理URL供用户手动下载
669
+ proxy_url = f"{SEEDANCE_API_BASE_URL}/proxy/{video_url}"
670
+ return None, f"⚠️ 视频生成完成但下载失败\n📎 原始URL: {video_url}\n🔗 代理URL: {proxy_url}\n请复制代理链接手动下载"
671
+ else:
672
+ return None, f"⚠️ 视频生成完成但未获取到URL"
673
+
674
+ # 检查失败状态
675
+ if status in ["failed", "error", "failure"]:
676
+ error_msg = video.get("error") or video.get("message") or "未知错误"
677
+ return None, f"❌ 视频生成失败: {error_msg}"
678
+
679
+ # 更新进度
680
+ elapsed = time.time() - start_time
681
+ idx = int(elapsed / poll_interval) % len(progress_chars)
682
+ print(f"[Seedance] {progress_chars[idx]} ��频生成中... 已等待 {int(elapsed)}秒")
683
+
684
+ # 等待下次轮询
685
+ time.sleep(poll_interval)
686
+
687
+ # 超时
688
+ return None, f"⏰ 等待超时({max_wait_seconds}秒),任务ID: {task_id}\n请稍后使用任务ID查询结果"
689
+
690
+ except httpx.ConnectError:
691
+ return None, f"❌ 无法连接到远程API服务器: {SEEDANCE_API_BASE_URL}\n请检查网络连接和服务器状态"
692
+ except Exception as e:
693
+ return None, f"❌ 发生错误: {str(e)}"
694
+
695
+
696
+ def generate_sora2_video_task(prompt: str, model: str, duration: int, orientation: str, image=None):
697
+ """生成 Sora2 视频 - 包含轮询等待逻辑"""
698
+ if not prompt or not prompt.strip():
699
+ return None, "❌ 请输入视频描述提示词"
700
+
701
+ max_wait_seconds = 900 # Sora2 可能需要更长时间,最大等待15分钟
702
+ poll_interval = 15 # 每15秒轮询一次
703
+
704
+ try:
705
+ # 如果有图片,转换为URL
706
+ image_urls = []
707
+ if image is not None:
708
+ print("[Sora2] 📤 正在处理图片...")
709
+ image_url = upload_image_to_url(image)
710
+ if not image_url:
711
+ return None, "❌ 图片处理失败"
712
+ image_urls.append(image_url)
713
+ print(f"[Sora2] ✅ 图片处理成功")
714
+
715
+ # 创建视频任务
716
+ mode = "图生视频" if image_urls else "文生视频"
717
+ print(f"[Sora2] 🎬 正在提交{mode}任务到远程服务器...")
718
+
719
+ create_result = create_sora2_video(prompt, model, duration, orientation, image_urls)
720
+
721
+ if not create_result.get("success"):
722
+ return None, f"❌ 创建任务失败: {create_result.get('message', '未知错误')}"
723
+
724
+ # 提取task_id
725
+ task_data = create_result.get("data", {})
726
+ task = task_data.get("task", {})
727
+ task_id = task.get("task_id") or task_data.get("taskId") or task_data.get("task_id") or task_data.get("id")
728
+
729
+ if not task_id:
730
+ return None, f"⚠️ 任务已提交({mode}),但无法获取任务ID,请稍后手动查询"
731
+
732
+ print(f"[Sora2] ✅ 任务创建成功! 任务ID: {task_id}")
733
+
734
+ # 轮询等待视频生成完成
735
+ start_time = time.time()
736
+ elapsed = 0
737
+ progress_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
738
+
739
+ while elapsed < max_wait_seconds:
740
+ # 查询任务状态
741
+ video = query_sora2_video(task_id)
742
+
743
+ if video:
744
+ status = (video.get("status") or "").lower()
745
+ video_url = video.get("video_url") or video.get("videoUrl") or video.get("url")
746
+ progress = video.get("progress", 0)
747
+
748
+ # 检查完成状态
749
+ if status in ["completed", "success", "done", "finished", "succeeded"]:
750
+ print(f"[Sora2] 🎉 视频生成完成!")
751
+ if video_url:
752
+ print(f"[Sora2] 📎 视频远程地址: {video_url}")
753
+ # Sora2 视频不需要代理
754
+ local_path = download_video_to_local(video_url, use_seedance_proxy=False)
755
+ if local_path:
756
+ return local_path, f"✅ 视频生成成功! ({mode})\n⏱️ 耗时: {int(elapsed)}秒\n🔗 远程服务: {SORA2_API_BASE_URL}\n📎 视频URL: {video_url}\n💡 已下载到本地"
757
+ else:
758
+ return None, f"⚠️ 视频生成完成但下载失败\n📎 视频URL: {video_url}\n请复制链接手动下载"
759
+ else:
760
+ return None, f"⚠️ 视频生成完成但未获取到URL"
761
+
762
+ # 检查失败状态
763
+ if status in ["failed", "error", "failure"]:
764
+ error_msg = video.get("error") or video.get("message") or video.get("detail", {}).get("message") or "未知错误"
765
+ return None, f"❌ 视频生成失败: {error_msg}"
766
+
767
+ # 显示进度
768
+ if progress > 0:
769
+ print(f"[Sora2] 📊 进度: {progress}%")
770
+
771
+ # 更新进度
772
+ elapsed = time.time() - start_time
773
+ idx = int(elapsed / poll_interval) % len(progress_chars)
774
+ print(f"[Sora2] {progress_chars[idx]} 视频生成中... 已等待 {int(elapsed)}秒")
775
+
776
+ # 等待下次轮询
777
+ time.sleep(poll_interval)
778
+
779
+ # 超时
780
+ return None, f"⏰ 等待超时({max_wait_seconds}秒),任务ID: {task_id}\n请稍后使用任务ID查询结果"
781
+
782
+ except httpx.ConnectError:
783
+ return None, f"❌ 无法连接到远程API服务器: {SORA2_API_BASE_URL}\n请检查网络连接和服务器状态"
784
+ except Exception as e:
785
+ return None, f"❌ 发生错误: {str(e)}"
786
+
787
+
788
+ def generate_video(provider: str, prompt: str, model: str, duration: int, ratio: str, image=None):
789
+ """统一的视频生成入口函数"""
790
+ if provider == "sora2":
791
+ return generate_sora2_video_task(prompt, model, duration, ratio, image)
792
+ elif provider == "sora2free":
793
+ # Sora2免费 只支持文生视频,不支持图片
794
+ return generate_sora2free_video(prompt, model)
795
+ else:
796
+ return generate_seedance_video(prompt, model, duration, ratio, image)
797
+
798
+
799
+ # ========== 样例视频配置 ==========
800
+ SAMPLE_VIDEO_PATH = os.path.abspath(
801
+ os.path.join(os.path.dirname(__file__), "sample", "video.mp4")
802
+ )
803
+
804
+
805
+ def load_sample_video():
806
+ """加载样例视频到视频复刻区域"""
807
+ if os.path.exists(SAMPLE_VIDEO_PATH):
808
+ return SAMPLE_VIDEO_PATH
809
+ else:
810
+ return None
811
+
812
+
813
+ def extract_prompt_from_video(video_path):
814
+ """从视频中提取提示词"""
815
+ if not video_path:
816
+ return "❌ 请先上传视频", ""
817
+ try:
818
+ print(f"[Gradio] 🔍 正在分析视频提取提示词: {video_path}")
819
+ result = analyze_video(video_path, sora2_mode=True, stream=False)
820
+
821
+ # 提取英文提示词用于生成
822
+ en_match = re.search(r'## SORA2 Prompt \(English\)\s*```\s*(.*?)\s*```', result, re.DOTALL)
823
+ en_prompt = en_match.group(1).strip() if en_match else ""
824
+
825
+ # 如果没提取到英文,尝试提取中文
826
+ if not en_prompt:
827
+ zh_match = re.search(r'## SORA2 提示词 \(中文\)\s*```\s*(.*?)\s*```', result, re.DOTALL)
828
+ en_prompt = zh_match.group(1).strip() if zh_match else ""
829
+
830
+ return result, en_prompt
831
+ except Exception as e:
832
+ return f"❌ 提示词提取失败: {str(e)}", ""
833
+
834
+
835
+ # 构建Gradio界面
836
+ def create_ui():
837
+ with gr.Blocks(
838
+ title="视频生成 - Seedance & Sora2 & Sora2免费"
839
+ ) as demo:
840
+
841
+ # 头部
842
+ gr.Markdown(f"""
843
+ # 🎬 AI 视频生成
844
+ **支持 Seedance、Sora2 和 Sora2免费 三种模型提供者**
845
+
846
+ 🔗 Seedance 服务: `{SEEDANCE_API_BASE_URL}`
847
+ 🔗 Sora2 服务: `{SORA2_API_BASE_URL}`
848
+ 🔗 Sora2免费 服务: `{SORA2FREE_API_BASE_URL}`
849
+ """)
850
+
851
+ # 主布局:左侧输入区域,右侧输出区域
852
+ with gr.Row():
853
+ # 左侧:输入参数区域
854
+ with gr.Column(scale=1):
855
+ # 视频复刻功能区
856
+ with gr.Group():
857
+ gr.Markdown("### 📹 视频复刻 (上传视频提取提示词)")
858
+ with gr.Row():
859
+ source_video = gr.Video(
860
+ label="上传短视频",
861
+ sources=["upload"],
862
+ interactive=True,
863
+ height=200
864
+ )
865
+ # 上传按钮和样例按钮并排
866
+ with gr.Row():
867
+ load_sample_btn = gr.Button("📂 加载样例视频", variant="secondary", size="sm")
868
+ clear_video_btn = gr.Button("🗑️ 清空视频", variant="stop", size="sm")
869
+ extract_btn = gr.Button("🔍 提取视频提示词", variant="secondary")
870
+ extraction_result = gr.Textbox(
871
+ label="提取结果分析",
872
+ placeholder="提取出的提示词分析将显示在这里...",
873
+ interactive=False,
874
+ lines=5
875
+ )
876
+
877
+ # 提示词输入
878
+ gr.Markdown("### ✍️ 视频配置")
879
+ prompt = gr.Textbox(
880
+ label="提示词 (Prompt)",
881
+ placeholder="(确认或输入) 描述你想生成的视频内容...",
882
+ lines=4,
883
+ max_lines=8
884
+ )
885
+ gr.Markdown("*您可以修改提取出的提示词或直接输入新提示词*")
886
+
887
+ # 模型提供者选择
888
+ provider = gr.Radio(
889
+ label="模型提供者",
890
+ choices=PROVIDER_OPTIONS,
891
+ value="seedance",
892
+ interactive=True
893
+ )
894
+
895
+ # Sora2免费 每日免费次数提示
896
+ sora2free_note = gr.Markdown(
897
+ "📌 **Sora2免费**:每天免费10次,仅支持文生视频",
898
+ visible=False
899
+ )
900
+
901
+ # 模型选择
902
+ model = gr.Dropdown(
903
+ label="模型 (model)",
904
+ choices=[m[0] for m in SEEDANCE_MODEL_OPTIONS],
905
+ value=SEEDANCE_MODEL_OPTIONS[0][0],
906
+ interactive=True
907
+ )
908
+
909
+ # 时长和比例并排
910
+ with gr.Row(visible=True) as duration_ratio_row:
911
+ # 时长选择
912
+ with gr.Column(scale=1):
913
+ duration = gr.Dropdown(
914
+ label="时长 (duration)",
915
+ choices=[str(d) for d in SEEDANCE_DURATION_OPTIONS],
916
+ value="5",
917
+ interactive=True
918
+ )
919
+ # Seedance 快捷按钮组
920
+ with gr.Row(visible=True) as seedance_duration_btns:
921
+ btn_4s = gr.Button("4s")
922
+ btn_5s = gr.Button("5s", variant="primary")
923
+ btn_8s = gr.Button("8s")
924
+ btn_12s = gr.Button("12s")
925
+ # Sora2 快捷按钮组
926
+ with gr.Row(visible=False) as sora2_duration_btns:
927
+ btn_10s = gr.Button("10s", variant="primary")
928
+ btn_15s = gr.Button("15s")
929
+
930
+ # 比例选择
931
+ with gr.Column(scale=1):
932
+ ratio = gr.Dropdown(
933
+ label="比例 (ratio/orientation)",
934
+ choices=[r[0] for r in SEEDANCE_RATIO_OPTIONS],
935
+ value=SEEDANCE_RATIO_OPTIONS[1][0],
936
+ interactive=True
937
+ )
938
+
939
+ # 图片上传(可选) - 显示缩略图
940
+ gr.Markdown("### 视频图片 (Optional)")
941
+ image = gr.Image(
942
+ label="上传参考图片 (图生视频模式)",
943
+ type="filepath",
944
+ sources=["upload"],
945
+ interactive=True,
946
+ height=200
947
+ )
948
+ image_note = gr.Markdown("*当前模型最多支持1张参考图*", visible=True)
949
+
950
+ # 生成按钮
951
+ gr.Markdown("*提交后请耐心等待,视频生成通常需要1-5分钟*")
952
+ generate_btn = gr.Button("🎬 生成视频", variant="primary")
953
+
954
+ # 右侧:输出结果区域
955
+ with gr.Column(scale=1):
956
+ gr.Markdown("### 生成结果")
957
+ video_output = gr.Video(
958
+ label="生成的视频",
959
+ interactive=False,
960
+ height=350
961
+ )
962
+ status_output = gr.Textbox(
963
+ label="状态信息",
964
+ interactive=False,
965
+ lines=6
966
+ )
967
+
968
+ # 事件绑定 - 模型提供者切换
969
+ def update_options_for_provider(provider_value):
970
+ """根据提供者更新模型、时长、比例选项"""
971
+ if provider_value == "sora2":
972
+ model_choices = [m[0] for m in SORA2_MODEL_OPTIONS]
973
+ model_value = SORA2_MODEL_OPTIONS[0][0]
974
+ duration_choices = [str(d) for d in SORA2_DURATION_OPTIONS]
975
+ duration_value = "10"
976
+ ratio_choices = [r[0] for r in SORA2_RATIO_OPTIONS]
977
+ ratio_value = SORA2_RATIO_OPTIONS[1][0] # landscape 默认
978
+ seedance_btns_visible = False
979
+ sora2_btns_visible = True
980
+ image_visible = True
981
+ image_note_visible = True
982
+ duration_ratio_visible = True
983
+ sora2free_note_visible = False
984
+ generate_btn_interactive = SORA2_ENABLED # 选择 sora2 时根据配置控制按钮
985
+ elif provider_value == "sora2free":
986
+ model_choices = [m[0] for m in SORA2FREE_MODEL_OPTIONS]
987
+ model_value = SORA2FREE_MODEL_OPTIONS[0][0]
988
+ duration_choices = []
989
+ duration_value = None
990
+ ratio_choices = []
991
+ ratio_value = None
992
+ seedance_btns_visible = False
993
+ sora2_btns_visible = False
994
+ image_visible = False # Sora2免费不支持图生视频
995
+ image_note_visible = False
996
+ duration_ratio_visible = False # Sora2免费模型名已包含时长和比例
997
+ sora2free_note_visible = True
998
+ generate_btn_interactive = True # sora2free 始终可用
999
+ else:
1000
+ model_choices = [m[0] for m in SEEDANCE_MODEL_OPTIONS]
1001
+ model_value = SEEDANCE_MODEL_OPTIONS[0][0]
1002
+ duration_choices = [str(d) for d in SEEDANCE_DURATION_OPTIONS]
1003
+ duration_value = "5"
1004
+ ratio_choices = [r[0] for r in SEEDANCE_RATIO_OPTIONS]
1005
+ ratio_value = SEEDANCE_RATIO_OPTIONS[1][0] # 16:9 默认
1006
+ seedance_btns_visible = True
1007
+ sora2_btns_visible = False
1008
+ image_visible = True
1009
+ image_note_visible = True
1010
+ duration_ratio_visible = True
1011
+ sora2free_note_visible = False
1012
+ generate_btn_interactive = True # seedance 始终可用
1013
+
1014
+ return (
1015
+ gr.update(choices=model_choices, value=model_value),
1016
+ gr.update(choices=duration_choices, value=duration_value),
1017
+ gr.update(choices=ratio_choices, value=ratio_value),
1018
+ gr.update(visible=seedance_btns_visible),
1019
+ gr.update(visible=sora2_btns_visible),
1020
+ gr.update(visible=image_visible),
1021
+ gr.update(visible=image_note_visible),
1022
+ gr.update(visible=duration_ratio_visible),
1023
+ gr.update(visible=sora2free_note_visible),
1024
+ gr.update(interactive=generate_btn_interactive)
1025
+ )
1026
+
1027
+ provider.change(
1028
+ fn=update_options_for_provider,
1029
+ inputs=[provider],
1030
+ outputs=[model, duration, ratio, seedance_duration_btns, sora2_duration_btns, image, image_note, duration_ratio_row, sora2free_note, generate_btn]
1031
+ )
1032
+
1033
+ # 提取提示词
1034
+ extract_btn.click(
1035
+ fn=extract_prompt_from_video,
1036
+ inputs=[source_video],
1037
+ outputs=[extraction_result, prompt],
1038
+ show_progress=True
1039
+ )
1040
+
1041
+ # 加载样例视频
1042
+ def handle_load_sample():
1043
+ if os.path.exists(SAMPLE_VIDEO_PATH):
1044
+ return SAMPLE_VIDEO_PATH, f"✅ 已加载样例视频: {SAMPLE_VIDEO_PATH}"
1045
+ else:
1046
+ return None, f"❌ 样例视频不存在: {SAMPLE_VIDEO_PATH}"
1047
+
1048
+ load_sample_btn.click(
1049
+ fn=handle_load_sample,
1050
+ outputs=[source_video, extraction_result],
1051
+ show_progress=True
1052
+ )
1053
+
1054
+ # 清空视频
1055
+ clear_video_btn.click(
1056
+ fn=lambda: (None, ""),
1057
+ outputs=[source_video, extraction_result]
1058
+ )
1059
+
1060
+ # Seedance 时长快捷按钮
1061
+ btn_4s.click(fn=lambda: "4", outputs=duration)
1062
+ btn_5s.click(fn=lambda: "5", outputs=duration)
1063
+ btn_8s.click(fn=lambda: "8", outputs=duration)
1064
+ btn_12s.click(fn=lambda: "12", outputs=duration)
1065
+
1066
+ # Sora2 时长快捷按钮
1067
+ btn_10s.click(fn=lambda: "10", outputs=duration)
1068
+ btn_15s.click(fn=lambda: "15", outputs=duration)
1069
+
1070
+ # 生成视频
1071
+ def process_generate(provider_val, prompt_text, model_text, duration_val, ratio_text, image_file):
1072
+ if provider_val == "sora2":
1073
+ # Sora2: 转换模型名称和比例
1074
+ model_value = next((m[1] for m in SORA2_MODEL_OPTIONS if m[0] == model_text), SORA2_MODEL_OPTIONS[0][1])
1075
+ ratio_value = next((r[1] for r in SORA2_RATIO_OPTIONS if r[0] == ratio_text), SORA2_RATIO_OPTIONS[0][1])
1076
+ elif provider_val == "sora2free":
1077
+ # Sora2免费: 转换模型名称
1078
+ model_value = next((m[1] for m in SORA2FREE_MODEL_OPTIONS if m[0] == model_text), SORA2FREE_MODEL_OPTIONS[0][1])
1079
+ ratio_value = ""
1080
+ else:
1081
+ # Seedance: 转换模型名称和比例
1082
+ model_value = next((m[1] for m in SEEDANCE_MODEL_OPTIONS if m[0] == model_text), SEEDANCE_MODEL_OPTIONS[0][1])
1083
+ ratio_value = next((r[1] for r in SEEDANCE_RATIO_OPTIONS if r[0] == ratio_text), SEEDANCE_RATIO_OPTIONS[1][1])
1084
+
1085
+ return generate_video(provider_val, prompt_text, model_value, int(duration_val) if duration_val else 5, ratio_value, image_file)
1086
+
1087
+ generate_btn.click(
1088
+ fn=process_generate,
1089
+ inputs=[provider, prompt, model, duration, ratio, image],
1090
+ outputs=[video_output, status_output],
1091
+ show_progress=True
1092
+ )
1093
+
1094
+ return demo
1095
 
 
 
 
 
 
 
1096
 
 
1097
  if __name__ == "__main__":
1098
+ print(f"[Gradio] 🚀 启动 AI 视频生成客户端 (Seedance & Sora2 & Sora2免费)")
1099
+ print(f"[Gradio] 🔗 Seedance API: {SEEDANCE_API_BASE_URL}")
1100
+ print(f"[Gradio] 🔗 Sora2 API: {SORA2_API_BASE_URL}")
1101
+ print(f"[Gradio] 🔗 Sora2免费 API: {SORA2FREE_API_BASE_URL}")
1102
+ print(f"[Gradio] 🔑 Seedance 鉴权: {'已配置' if SEEDANCE_AUTH_TOKEN else '未配置'}")
1103
+ print(f"[Gradio] 🔑 Sora2 鉴权: {'已配置' if SORA2_API_KEY else '未配置'}, 启用: {SORA2_ENABLED}")
1104
+ print(f"[Gradio] 🔑 Sora2免费 鉴权: {'已配置' if SORA2FREE_API_KEY else '未配置'}")
1105
+
1106
+ demo = create_ui()
1107
+ port = int(os.getenv("GRADIO_PORT", "7860"))
1108
+ demo.launch(
1109
+ server_name="0.0.0.0",
1110
+ server_port=port,
1111
+ share=False,
1112
+ show_error=True
1113
+ )