194130157a commited on
Commit
f4ba449
·
verified ·
1 Parent(s): 72df0f2

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +446 -0
app.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import subprocess
3
+ import os
4
+ import shutil
5
+ import time
6
+ import json
7
+ import requests
8
+ import re
9
+ import threading
10
+ import glob
11
+ import traceback
12
+ from datetime import datetime
13
+
14
+ # ==========================================
15
+ # 0. 环境自动修复
16
+ # ==========================================
17
+ def install_package(package, pip_name=None):
18
+ pip_name = pip_name or package
19
+ try:
20
+ __import__(package)
21
+ except ImportError:
22
+ print(f"📦 正在自动安装缺失库: {pip_name} ...")
23
+ subprocess.check_call([sys.executable, "-m", "pip", "install", pip_name])
24
+
25
+ install_package("gradio")
26
+ install_package("requests")
27
+
28
+ import gradio as gr
29
+
30
+ # ================= 配置区域 =================
31
+
32
+ API_KEY = "5d8189fa5abf2d541fa69e4c56e94a49" # API 密钥
33
+ TASK_BASE_URL = "https://api.kie.ai/api/v1/jobs"
34
+ UPLOAD_BASE_URL = "https://kieai.redpandaai.co/api/file-stream-upload"
35
+
36
+ # 任务存储根目录 (建议挂载持久化存储)
37
+ TASKS_ROOT_DIR = "motion_tasks_data"
38
+
39
+ HEADERS = {
40
+ "Authorization": f"Bearer {API_KEY}"
41
+ }
42
+
43
+ MODEL_NAME = "kling-2.6/motion-control"
44
+
45
+ # ================= 核心工具函数 (Kling API 逻辑) =================
46
+
47
+ def ensure_video_resolution(video_path, logger_func):
48
+ """
49
+ 使用 ffmpeg 检查并调整视频分辨率 (至少 720p)。
50
+ """
51
+ if not video_path: return None
52
+
53
+ logger_func(f"🔍 正在检查视频分辨率: {os.path.basename(video_path)}")
54
+ output_path = os.path.splitext(video_path)[0] + "_720p.mp4"
55
+
56
+ # 简单的 ffmpeg 命令:短边至少 720
57
+ cmd = [
58
+ "ffmpeg", "-y", "-i", video_path,
59
+ "-vf", "scale='if(gt(iw,ih),-2,720)':'if(gt(iw,ih),720,-2)'",
60
+ "-c:v", "libx264", "-preset", "fast", "-crf", "23",
61
+ "-c:a", "copy",
62
+ output_path
63
+ ]
64
+
65
+ try:
66
+ # 尝试调用系统 ffmpeg
67
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
68
+ logger_func(f"✅ 视频已标准化为 720p: {os.path.basename(output_path)}")
69
+ return output_path
70
+ except Exception as e:
71
+ logger_func(f"⚠️ 分辨率调整失败 (可能缺少ffmpeg),将使用原视频: {e}")
72
+ return video_path
73
+
74
+ def upload_file_to_kie(file_path, logger_func):
75
+ """上传文件到 KIE 服务器"""
76
+ if not file_path: return None
77
+
78
+ file_name = os.path.basename(file_path)
79
+ # 文件名清洗
80
+ file_name = "".join(x for x in file_name if x.isalnum() or x in "._- ")
81
+ logger_func(f"⬆️ 正在上传: {file_name} ...")
82
+
83
+ try:
84
+ files = {'file': (file_name, open(file_path, 'rb'))}
85
+ data = {'uploadPath': 'images/user-uploads', 'fileName': file_name}
86
+
87
+ response = requests.post(UPLOAD_BASE_URL, headers=HEADERS, files=files, data=data, timeout=120)
88
+ result = response.json()
89
+
90
+ if result.get("success") and result.get("code") == 200:
91
+ download_url = result["data"]["downloadUrl"]
92
+ logger_func("✅ 上传成功")
93
+ return download_url
94
+ else:
95
+ logger_func(f"❌ 上传失败: {result}")
96
+ return None
97
+ except Exception as e:
98
+ logger_func(f"❌ 上传异常: {e}")
99
+ return None
100
+
101
+ def create_motion_task(image_url, video_url, prompt, logger_func):
102
+ """创建动作迁移任务"""
103
+ url = f"{TASK_BASE_URL}/createTask"
104
+ task_headers = HEADERS.copy()
105
+ task_headers["Content-Type"] = "application/json"
106
+
107
+ payload = {
108
+ "model": MODEL_NAME,
109
+ "input": {
110
+ "prompt": prompt,
111
+ "input_urls": [image_url],
112
+ "video_urls": [video_url],
113
+ "character_orientation": "video",
114
+ "mode": "720p"
115
+ }
116
+ }
117
+
118
+ try:
119
+ response = requests.post(url, headers=task_headers, json=payload, timeout=30)
120
+ data = response.json()
121
+ if data.get("code") == 200:
122
+ tid = data["data"]["taskId"]
123
+ logger_func(f"🚀 任务创建成功,TaskID: {tid}")
124
+ return tid
125
+ else:
126
+ logger_func(f"❌ API 拒绝任务: {data}")
127
+ return None
128
+ except Exception as e:
129
+ logger_func(f"❌ 请求异常: {e}")
130
+ return None
131
+
132
+ def wait_for_result(task_id, logger_func):
133
+ """轮询任务结果"""
134
+ url = f"{TASK_BASE_URL}/recordInfo?taskId={task_id}"
135
+ start_time = time.time()
136
+ timeout = 900 # 15分钟超时
137
+
138
+ logger_func("⏳ 开始轮询结果...")
139
+
140
+ while True:
141
+ if time.time() - start_time > timeout:
142
+ return None, "Timeout"
143
+
144
+ try:
145
+ response = requests.get(url, headers=HEADERS, timeout=30)
146
+ data = response.json()
147
+
148
+ if data.get("code") != 200:
149
+ time.sleep(5)
150
+ continue
151
+
152
+ state = data["data"]["state"]
153
+
154
+ if state == "success":
155
+ result_json = json.loads(data["data"]["resultJson"])
156
+ video_url = result_json["resultUrls"][0]
157
+ return video_url, "Success"
158
+ elif state == "fail":
159
+ fail_msg = data["data"].get("failMsg", "Unknown error")
160
+ return None, f"Fail: {fail_msg}"
161
+
162
+ # logger_func(f"Generated State: {state}") # 可选:减少日志量
163
+ time.sleep(5)
164
+
165
+ except Exception as e:
166
+ logger_func(f"⚠️ 轮询网络波动: {e}")
167
+ time.sleep(5)
168
+
169
+ def download_video_to_local(url, save_path, logger_func):
170
+ """将生成的视频下载到本地文件夹,实现持久化"""
171
+ try:
172
+ logger_func(f"⬇️ 正在下载结果到本地...")
173
+ resp = requests.get(url, stream=True, timeout=60)
174
+ if resp.status_code == 200:
175
+ with open(save_path, 'wb') as f:
176
+ for chunk in resp.iter_content(chunk_size=8192):
177
+ f.write(chunk)
178
+ return True
179
+ return False
180
+ except Exception as e:
181
+ logger_func(f"❌ 下载保存失败: {e}")
182
+ return False
183
+
184
+ # ================= V9.1 异步任务管理器 (Task Isolation) =================
185
+
186
+ class TaskManager:
187
+ def __init__(self, root_dir=TASKS_ROOT_DIR):
188
+ self.root_dir = root_dir
189
+ os.makedirs(self.root_dir, exist_ok=True)
190
+
191
+ def create_task(self, task_name_prefix="motion"):
192
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
193
+ task_id = f"{task_name_prefix}_{timestamp}"
194
+ task_dir = os.path.join(self.root_dir, task_id)
195
+ os.makedirs(task_dir, exist_ok=True)
196
+
197
+ # 结果子目录
198
+ os.makedirs(os.path.join(task_dir, "results"), exist_ok=True)
199
+
200
+ with open(os.path.join(task_dir, "log.txt"), "w", encoding="utf-8") as f:
201
+ f.write(f"[{datetime.now().strftime('%H:%M:%S')}] 任务创建成功: {task_id}\n")
202
+
203
+ self.update_status(task_id, "running")
204
+ return task_id, task_dir
205
+
206
+ def log(self, task_id, message):
207
+ timestamp = datetime.now().strftime('%H:%M:%S')
208
+ log_line = f"[{timestamp}] {message}\n"
209
+ log_path = os.path.join(self.root_dir, task_id, "log.txt")
210
+ try:
211
+ with open(log_path, "a", encoding="utf-8") as f:
212
+ f.write(log_line)
213
+ except: pass
214
+ print(f"[{task_id}] {message}")
215
+
216
+ def update_status(self, task_id, status):
217
+ status_path = os.path.join(self.root_dir, task_id, "status.json")
218
+ data = { "status": status, "last_update": time.time() }
219
+ with open(status_path, "w", encoding="utf-8") as f:
220
+ json.dump(data, f)
221
+
222
+ def get_task_info(self, task_id):
223
+ task_dir = os.path.join(self.root_dir, task_id)
224
+ if not os.path.exists(task_dir): return None
225
+
226
+ log_content = ""
227
+ try:
228
+ with open(os.path.join(task_dir, "log.txt"), "r", encoding="utf-8") as f:
229
+ log_content = f.read()
230
+ except: log_content = "日志读取中..."
231
+
232
+ # 扫描结果文件夹中的所有视频
233
+ results_dir = os.path.join(task_dir, "results")
234
+ result_files = sorted(glob.glob(os.path.join(results_dir, "*.mp4")))
235
+
236
+ return {
237
+ "id": task_id,
238
+ "log": log_content,
239
+ "results": result_files
240
+ }
241
+
242
+ def list_tasks(self):
243
+ if not os.path.exists(self.root_dir): return []
244
+ tasks = [d for d in os.listdir(self.root_dir) if os.path.isdir(os.path.join(self.root_dir, d))]
245
+ # 按修改时间倒序
246
+ tasks.sort(key=lambda x: os.path.getmtime(os.path.join(self.root_dir, x)), reverse=True)
247
+ return tasks
248
+
249
+ def clear_all_tasks(self):
250
+ try:
251
+ shutil.rmtree(self.root_dir)
252
+ os.makedirs(self.root_dir, exist_ok=True)
253
+ return "✅ 已清空所有历史任务"
254
+ except Exception as e:
255
+ return f"❌ 清空失败: {e}"
256
+
257
+ task_manager = TaskManager()
258
+
259
+ # ================= 后台工作线程 (Worker) =================
260
+
261
+ def background_worker(task_id, images, video, prompt):
262
+ task_dir = os.path.join(task_manager.root_dir, task_id)
263
+ results_dir = os.path.join(task_dir, "results")
264
+
265
+ def log_wrapper(msg):
266
+ task_manager.log(task_id, msg)
267
+
268
+ try:
269
+ log_wrapper("🚀 后台进程启动。即使刷新页面,任务也会继续运行。")
270
+
271
+ if not images or not video:
272
+ log_wrapper("❌ 缺少图片或视频,任务终止。")
273
+ task_manager.update_status(task_id, "failed")
274
+ return
275
+
276
+ # 1. 本地文件准备 (复制到任务目录,防止gradio临时文件消失)
277
+ local_video_name = os.path.basename(video)
278
+ local_video_path = os.path.join(task_dir, local_video_name)
279
+ shutil.copy(video, local_video_path)
280
+
281
+ local_images = []
282
+ for img in images:
283
+ # 处理 Gradio 可能是文件对象或路径的情况
284
+ src_path = img.name if hasattr(img, 'name') else img
285
+ dst_path = os.path.join(task_dir, os.path.basename(src_path))
286
+ shutil.copy(src_path, dst_path)
287
+ local_images.append(dst_path)
288
+
289
+ # 2. 视频预处理
290
+ log_wrapper("--- 步骤 1: 检查并修复视频分辨率 ---")
291
+ processed_video = ensure_video_resolution(local_video_path, log_wrapper)
292
+
293
+ # 3. 上传视频
294
+ log_wrapper("--- 步骤 2: 上传驱动视频 ---")
295
+ video_public_url = upload_file_to_kie(processed_video, log_wrapper)
296
+ if not video_public_url:
297
+ log_wrapper("❌ 视频上传失败,流程终止。")
298
+ task_manager.update_status(task_id, "failed")
299
+ return
300
+
301
+ # 4. 循环处理图片
302
+ total = len(local_images)
303
+ success_count = 0
304
+
305
+ for i, img_path in enumerate(local_images):
306
+ log_wrapper(f"\n🎥 [处理进度 {i+1}/{total}] 图片: {os.path.basename(img_path)}")
307
+
308
+ # 上传图片
309
+ img_public_url = upload_file_to_kie(img_path, log_wrapper)
310
+ if not img_public_url:
311
+ continue
312
+
313
+ # 创建任务
314
+ api_task_id = create_motion_task(img_public_url, video_public_url, prompt, log_wrapper)
315
+
316
+ if api_task_id:
317
+ # 等待结果
318
+ final_video_url, msg = wait_for_result(api_task_id, log_wrapper)
319
+
320
+ if final_video_url:
321
+ log_wrapper("✅ 生成成功,正在下载...")
322
+ # 保存文件名: output_01_origName.mp4
323
+ save_name = f"output_{i+1:02d}_{os.path.basename(img_path).split('.')[0]}.mp4"
324
+ save_full_path = os.path.join(results_dir, save_name)
325
+
326
+ if download_video_to_local(final_video_url, save_full_path, log_wrapper):
327
+ log_wrapper(f"💾 已保存到: {save_name}")
328
+ success_count += 1
329
+ else:
330
+ log_wrapper("⚠️ 下载失败,仅提供链接")
331
+ else:
332
+ log_wrapper(f"❌ 生成失败: {msg}")
333
+ else:
334
+ log_wrapper("❌ 任务创建失败")
335
+
336
+ log_wrapper(f"\n🎉 任务结束。成功: {success_count}/{total}")
337
+ task_manager.update_status(task_id, "completed")
338
+
339
+ except Exception as e:
340
+ log_wrapper(f"💥 致命错误: {traceback.format_exc()}")
341
+ task_manager.update_status(task_id, "error")
342
+
343
+ # ================= 交互逻辑 =================
344
+
345
+ def submit_new_task(images, video, prompt):
346
+ if not images: return "❌ 请上传至少一张图片", None
347
+ if not video: return "❌ 请上传参考视频", None
348
+
349
+ # 任务名以第一张图片命名
350
+ first_img_name = os.path.basename(images[0].name if hasattr(images[0], 'name') else images[0])
351
+ task_name = f"motion_{first_img_name[:10]}"
352
+
353
+ task_id, task_dir = task_manager.create_task(task_name)
354
+
355
+ # 启动线程
356
+ t = threading.Thread(target=background_worker, args=(task_id, images, video, prompt))
357
+ t.start()
358
+
359
+ return f"✅ 任务已后台启动!ID: {task_id}\n请切换到【任务监控】标签页查看进度。", task_id
360
+
361
+ def refresh_task_list():
362
+ tasks = task_manager.list_tasks()
363
+ return gr.Dropdown(choices=tasks, value=tasks[0] if tasks else None)
364
+
365
+ def get_task_details(task_id):
366
+ if not task_id: return "请选择任务", []
367
+ info = task_manager.get_task_info(task_id)
368
+ if not info: return "任务不存在", []
369
+
370
+ # 返回: 日志内容, 结果文件列表(用于Gallery)
371
+ return info['log'], info['results']
372
+
373
+ def handle_clear_storage():
374
+ msg = task_manager.clear_all_tasks()
375
+ return msg, gr.Dropdown(choices=[], value=None)
376
+
377
+ # ================= UI 构建 =================
378
+
379
+ with gr.Blocks(title="Kling Motion V9.1 Async") as demo:
380
+ gr.Markdown("## 💃 Kling 动作迁移批量工厂 (V9.1 异步防断连版)")
381
+ gr.Markdown("**特性**:支持多任务并发、刷新页面不中断、结果自动保存到硬盘、视频分辨率自动修复。")
382
+
383
+ with gr.Tabs():
384
+ # --- Tab 1: 提交任务 ---
385
+ with gr.Tab("🚀 提交新任务"):
386
+ with gr.Row():
387
+ with gr.Column():
388
+ img_input = gr.File(label="1. 上传图片 (支持多选)", file_count="multiple", file_types=["image"])
389
+ video_input = gr.Video(label="2. 上传参考视频", format="mp4")
390
+ prompt_input = gr.Textbox(label="3. 提示词", value="The character is performing the action from the video.")
391
+ submit_btn = gr.Button("🔥 立即启动后台任务", variant="primary")
392
+ with gr.Column():
393
+ submit_result = gr.Textbox(label="提交结果", lines=2)
394
+ new_task_id_storage = gr.State()
395
+
396
+ # --- Tab 2: 监控任务 ---
397
+ with gr.Tab("📺 任务监控"):
398
+ with gr.Row():
399
+ with gr.Column(scale=1):
400
+ refresh_list_btn = gr.Button("🔄 刷新任务列表")
401
+ task_dropdown = gr.Dropdown(label="选择历史任务", choices=[], interactive=True)
402
+
403
+ gr.Markdown("### 📥 结果展示")
404
+ # Gallery 直接展示本地文件路径,Gradio 会自动处理 serving
405
+ result_gallery = gr.Gallery(label="生成结果 (本地保存)", columns=2, height="auto", allow_preview=True)
406
+
407
+ gr.Markdown("---")
408
+ clear_btn = gr.Button("🗑️ 清空所有任务", variant="stop")
409
+ clear_msg = gr.Label("")
410
+
411
+ with gr.Column(scale=2):
412
+ log_monitor = gr.Textbox(label="运行日志 (自动刷新)", lines=25, max_lines=25)
413
+ auto_timer = gr.Timer(2) # 2秒刷新一次
414
+
415
+ # --- 事件绑定 ---
416
+
417
+ # 提交
418
+ submit_btn.click(
419
+ submit_new_task,
420
+ [img_input, video_input, prompt_input],
421
+ [submit_result, new_task_id_storage]
422
+ )
423
+
424
+ # 提交成功后,自动更新下拉框并选中新任务
425
+ def auto_select_new_task(new_id):
426
+ all_tasks = task_manager.list_tasks()
427
+ return gr.Dropdown(choices=all_tasks, value=new_id)
428
+
429
+ submit_btn.click(auto_select_new_task, new_task_id_storage, task_dropdown)
430
+
431
+ # 刷新列表
432
+ refresh_list_btn.click(refresh_task_list, outputs=task_dropdown)
433
+
434
+ # 定时刷新日志和画廊
435
+ # 注意:outputs 对应 [日志框, 画廊组件]
436
+ auto_timer.tick(get_task_details, inputs=[task_dropdown], outputs=[log_monitor, result_gallery])
437
+ task_dropdown.change(get_task_details, inputs=[task_dropdown], outputs=[log_monitor, result_gallery])
438
+
439
+ # 清空
440
+ clear_btn.click(handle_clear_storage, outputs=[clear_msg, task_dropdown])
441
+
442
+ # 初始化
443
+ demo.load(refresh_task_list, outputs=task_dropdown)
444
+
445
+ if __name__ == "__main__":
446
+ demo.queue().launch(server_name="0.0.0.0", inbrowser=True)