194130157a commited on
Commit
0970e41
·
verified ·
1 Parent(s): 8ee5f2a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +318 -476
app.py CHANGED
@@ -1,604 +1,446 @@
1
- import json
2
- import os
3
  import subprocess
 
4
  import shutil
5
- import threading
 
 
6
  import re
 
 
7
  import traceback
8
- import zipfile
9
-
10
  from datetime import datetime
11
- from concurrent.futures import ThreadPoolExecutor, as_completed
12
 
13
  # ==========================================
14
- # 1. 配置与环境设置
15
  # ==========================================
 
 
 
 
 
 
 
16
 
 
 
17
 
 
18
 
 
19
 
 
 
 
20
 
 
 
21
 
 
 
 
22
 
 
23
 
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
- KIE_API_KEY = "5d8189fa5abf2d541fa69e4c56e94a49"
27
- KIE_TASK_URL = "https://api.kie.ai/api/v1/jobs"
28
- KIE_UPLOAD_URL = "https://kieai.redpandaai.co/api/file-stream-upload"
29
- MOTION_MODEL = "kling-2.6/motion-control"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- # 本地存储根目录
32
- TASKS_ROOT_DIR = "tasks_data"
33
- MAX_WORKERS = 5 # 控制同时处理图片的并发数
 
 
 
 
 
 
 
 
 
 
34
 
35
- # ==========================================
36
- # 2. 核心:任务管理器 (Task Manager)
37
- # 负责状态管理、日志读写、文件持久化
38
- # ==========================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  class TaskManager:
41
  def __init__(self, root_dir=TASKS_ROOT_DIR):
42
  self.root_dir = root_dir
43
  os.makedirs(self.root_dir, exist_ok=True)
44
 
45
- def create_task(self, original_desc="motion_transfer"):
46
- """创建一个新任务,返回任务ID和目录"""
47
  timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
48
- # 清理文件名中的非法字符
49
- clean_desc = re.sub(r'[\\/*?:"<>|]', "", original_desc)[:20] or "task"
50
- task_id = f"{timestamp}_{clean_desc}"
51
-
52
  task_dir = os.path.join(self.root_dir, task_id)
53
  os.makedirs(task_dir, exist_ok=True)
54
- os.makedirs(os.path.join(task_dir, "inputs"), exist_ok=True)
55
- os.makedirs(os.path.join(task_dir, "outputs"), exist_ok=True)
56
 
57
- # 初始化日志
58
- with open(os.path.join(task_dir, "log.txt"), "w", encoding="utf-8") as f:
59
- f.write(f"[{datetime.now().strftime('%H:%M:%S')}] 任务已创建: {task_id}\n")
60
-
61
-
62
-
63
-
64
-
65
-
66
-
67
 
68
- # 初始化状态
69
- self.update_status(task_id, "queued", progress=0, gallery=[])
70
 
 
71
  return task_id, task_dir
72
 
73
  def log(self, task_id, message):
74
- """写入日志"""
75
  timestamp = datetime.now().strftime('%H:%M:%S')
76
- print(f"[{task_id}] {message}")
77
  log_line = f"[{timestamp}] {message}\n"
78
  log_path = os.path.join(self.root_dir, task_id, "log.txt")
79
  try:
80
  with open(log_path, "a", encoding="utf-8") as f:
81
  f.write(log_line)
82
  except: pass
 
83
 
84
-
85
-
86
-
87
-
88
- def update_status(self, task_id, status, progress=0, result_zip=None, gallery=None):
89
- """更新状态JSON"""
90
  status_path = os.path.join(self.root_dir, task_id, "status.json")
91
-
92
- # 读取旧状态以保留 gallery 数据
93
- current_data = {}
94
- if os.path.exists(status_path):
95
- try:
96
- with open(status_path, 'r', encoding='utf-8') as f:
97
- current_data = json.load(f)
98
- except: pass
99
-
100
- data = {
101
- "status": status,
102
- "last_update": time.time(),
103
- "progress": progress,
104
- "result_zip": result_zip or current_data.get("result_zip"),
105
- "gallery": gallery if gallery is not None else current_data.get("gallery", [])
106
- }
107
-
108
  with open(status_path, "w", encoding="utf-8") as f:
109
  json.dump(data, f)
110
 
111
  def get_task_info(self, task_id):
112
- """获取任务详情用于UI展示"""
113
  task_dir = os.path.join(self.root_dir, task_id)
114
- if not os.path.exists(task_dir):
115
- return None
116
 
117
- # 读取日志
118
  log_content = ""
119
  try:
120
  with open(os.path.join(task_dir, "log.txt"), "r", encoding="utf-8") as f:
121
  log_content = f.read()
122
  except: log_content = "日志读取中..."
123
 
124
- # 读取状态
125
- status_data = {"status": "unknown", "gallery": [], "result_zip": None}
126
- try:
127
- with open(os.path.join(task_dir, "status.json"), "r", encoding="utf-8") as f:
128
- status_data = json.load(f)
129
- except: pass
130
-
131
  return {
132
  "id": task_id,
133
  "log": log_content,
134
- "status": status_data.get("status"),
135
- "gallery": status_data.get("gallery"),
136
- "result_zip": status_data.get("result_zip")
137
  }
138
 
139
  def list_tasks(self):
140
- """列出所有任务"""
141
  if not os.path.exists(self.root_dir): return []
142
  tasks = [d for d in os.listdir(self.root_dir) if os.path.isdir(os.path.join(self.root_dir, d))]
143
- # 按时间倒序
144
  tasks.sort(key=lambda x: os.path.getmtime(os.path.join(self.root_dir, x)), reverse=True)
145
  return tasks
146
-
147
- # 🔥🔥 新增方法:清空所有任务数据 🔥🔥
148
  def clear_all_tasks(self):
149
  try:
150
- # 物理删除根目录
151
- if os.path.exists(self.root_dir):
152
- shutil.rmtree(self.root_dir)
153
- # 重建根目录
154
  os.makedirs(self.root_dir, exist_ok=True)
155
- return True, "✅ 存储已清空所有历史任务已删除。"
156
  except Exception as e:
157
- return False, f"❌ 清空失败: {e}"
158
 
159
  task_manager = TaskManager()
160
 
 
161
 
162
-
163
-
164
-
165
-
166
-
167
-
168
-
169
-
170
-
171
- # ==========================================
172
- # 3. 业务逻辑 (Kie AI API)
173
- # ==========================================
174
-
175
- def ensure_video_resolution(video_path, output_dir):
176
- """处理视频分辨率"""
177
- if not video_path: return None
178
- output_path = os.path.join(output_dir, "driver_720p.mp4")
179
-
180
-
181
-
182
-
183
-
184
-
185
-
186
-
187
-
188
-
189
-
190
-
191
-
192
-
193
-
194
-
195
-
196
-
197
-
198
-
199
-
200
-
201
-
202
-
203
-
204
-
205
-
206
-
207
-
208
-
209
-
210
-
211
-
212
-
213
-
214
-
215
-
216
-
217
-
218
-
219
-
220
-
221
-
222
-
223
-
224
-
225
-
226
-
227
-
228
-
229
-
230
-
231
-
232
-
233
-
234
-
235
-
236
-
237
-
238
-
239
- # 如果已经处理过,直接返回
240
- if os.path.exists(output_path): return output_path
241
-
242
-
243
-
244
-
245
-
246
-
247
-
248
-
249
-
250
-
251
-
252
- cmd = ["ffmpeg", "-y", "-i", video_path, "-vf", "scale='if(gt(iw,ih),-2,720)':'if(gt(iw,ih),720,-2)'", "-c:v", "libx264", "-preset", "fast", "-crf", "23", "-c:a", "copy", output_path]
253
- try:
254
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
255
- return output_path
256
- except:
257
- return video_path # 失败则返回原视频
258
-
259
- def upload_file_to_kie(file_path):
260
- """上传文件到 Kie AI"""
261
- if not file_path or not os.path.exists(file_path): return None
262
- file_name = os.path.basename(file_path)
263
- # 清洗文件名
264
- file_name = "".join(x for x in file_name if x.isalnum() or x in "._- ")
265
- try:
266
- headers = {"Authorization": f"Bearer {KIE_API_KEY}"}
267
- files = {'file': (file_name, open(file_path, 'rb'))}
268
- data = {'uploadPath': 'images/user-uploads', 'fileName': file_name}
269
- resp = requests.post(KIE_UPLOAD_URL, headers=headers, files=files, data=data, timeout=120)
270
- if resp.json().get("success"):
271
- return resp.json()["data"]["downloadUrl"]
272
- except Exception as e:
273
- print(f"上传异常: {e}")
274
- return None
275
-
276
- def download_file(url, save_path):
277
- """通用下载函数"""
278
- try:
279
- headers = {"User-Agent": "Mozilla/5.0"}
280
- resp = requests.get(url, stream=True, headers=headers, timeout=300)
281
- if resp.status_code == 200:
282
- with open(save_path, "wb") as f:
283
- for chunk in resp.iter_content(chunk_size=8192):
284
- f.write(chunk)
285
- return True
286
- except: pass
287
- return False
288
-
289
- def run_single_kie_task(img_path, video_url, prompt, task_id):
290
- """执行单个 Kie AI 任务"""
291
- # 1. 上传图片
292
- img_url = upload_file_to_kie(img_path)
293
- if not img_url: return None, "Image Upload Failed"
294
-
295
- # 2. 创建任务
296
- headers = {"Authorization": f"Bearer {KIE_API_KEY}", "Content-Type": "application/json"}
297
- payload = {
298
- "model": MOTION_MODEL,
299
- "input": {
300
- "prompt": prompt,
301
- "input_urls": [img_url],
302
- "video_urls": [video_url],
303
- "character_orientation": "video",
304
- "mode": "720p"
305
- }
306
- }
307
-
308
-
309
- try:
310
- resp = requests.post(f"{KIE_TASK_URL}/createTask", headers=headers, json=payload, timeout=60)
311
- if resp.json().get("code") != 200:
312
- return None, f"Create Task Error: {resp.text}"
313
- remote_task_id = resp.json()["data"]["taskId"]
314
- except Exception as e:
315
- return None, str(e)
316
-
317
- # 3. 轮询
318
- start_time = time.time()
319
- while time.time() - start_time < 1800: # 30分钟超时
320
-
321
-
322
- try:
323
- r = requests.get(f"{KIE_TASK_URL}/recordInfo?taskId={remote_task_id}", headers=headers, timeout=30)
324
- if r.status_code == 200:
325
- d = r.json()
326
- state = d.get("data", {}).get("state")
327
-
328
- if state == "success":
329
- video_url = json.loads(d["data"]["resultJson"])["resultUrls"][0]
330
- return video_url, "Success"
331
- elif state == "fail":
332
- return None, f"Kie Task Failed: {d.get('data', {}).get('failReason')}"
333
- time.sleep(10)
334
- except:
335
- time.sleep(10)
336
-
337
- return None, "Timeout"
338
-
339
- # ==========================================
340
- # 4. 后台工作线程 (Worker)
341
- # ==========================================
342
-
343
- def background_worker(task_id, uploaded_images, video_path, prompt):
344
- task_manager.update_status(task_id, "running", progress=0)
345
- task_manager.log(task_id, "🚀 任务后台线程已启动")
346
-
347
  task_dir = os.path.join(task_manager.root_dir, task_id)
348
- input_dir = os.path.join(task_dir, "inputs")
349
- output_dir = os.path.join(task_dir, "outputs")
350
-
351
- local_images = []
352
 
 
 
 
353
  try:
354
- # 1. 本地文件归档 (将上传的临时文件复制到任务目录)
355
- task_manager.log(task_id, "📂 正在归档输入文件...")
356
 
357
- # 处理视频
358
- local_video_path = os.path.join(input_dir, "driver_video.mp4")
359
- shutil.copy(video_path, local_video_path)
 
 
 
 
 
 
360
 
361
- # 处理图片
362
- for idx, img_file in enumerate(uploaded_images):
363
- # 获取 Gradio File 对象路径 (img_file 是临时路径字符串或对象)
364
- src_path = img_file.name if hasattr(img_file, 'name') else img_file
365
- fname = f"image_{idx:03d}{os.path.splitext(src_path)[1]}"
366
- dst_path = os.path.join(input_dir, fname)
367
  shutil.copy(src_path, dst_path)
368
  local_images.append(dst_path)
369
-
370
- task_manager.log(task_id, f"✅ 文件归档完成。图片: {len(local_images)}张, 视频已保存。")
371
-
372
- # 2. 预处理驱动视频
373
- task_manager.log(task_id, "🔧 正在检查/处理驱动视频分辨率...")
374
- processed_video = ensure_video_resolution(local_video_path, input_dir)
375
 
376
- # 3. 上传驱动视频 (只上传一次)
377
- task_manager.log(task_id, "☁️ 正在上传驱动视频到云端...")
378
- video_public_url = upload_file_to_kie(processed_video)
379
  if not video_public_url:
380
- raise Exception("驱动视频上传失败,请检查网络或Key")
381
- task_manager.log(task_id, "✅ 驱动视频上传成功")
 
382
 
383
- # 4. 并发执行生成任务
384
- total_imgs = len(local_images)
385
- finished_count = 0
386
- success_results = [] # 存储本地生成的视频路径
387
 
388
- task_manager.log(task_id, f"🚀 开始并发生成,线程数: {MAX_WORKERS}")
389
-
390
- with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
391
- # 提交所有任务
392
- future_map = {}
393
- for i, img_path in enumerate(local_images):
394
- img_name = os.path.basename(img_path)
395
- task_manager.log(task_id, f"➕ 提交子任务: {img_name}")
396
- future = executor.submit(run_single_kie_task, img_path, video_public_url, prompt, task_id)
397
- future_map[future] = (i, img_name)
398
 
399
- # 等待结果
400
- for future in as_completed(future_map):
401
- idx, img_name = future_map[future]
402
- try:
403
- video_url, msg = future.result()
404
- if video_url:
405
- # 下载结果
406
- save_name = f"result_{idx:03d}_{int(time.time())}.mp4"
407
- save_path = os.path.join(output_dir, save_name)
408
- if download_file(video_url, save_path):
409
- success_results.append(save_path)
410
- task_manager.log(task_id, f"✅ [成功] {img_name} -> {save_name}")
411
- else:
412
- task_manager.log(task_id, f"⚠️ [下载失败] {img_name}")
413
- else:
414
- task_manager.log(task_id, f"❌ [生成失败] {img_name}: {msg}")
415
- except Exception as e:
416
- task_manager.log(task_id, f"❌ [异常] {img_name}: {e}")
417
 
418
- finished_count += 1
419
- # 更新进度条
420
- progress_val = int((finished_count / total_imgs) * 100)
421
- # 实时更新 Gallery
422
- current_gallery = success_results.copy()
423
- task_manager.update_status(task_id, "running", progress=progress_val, gallery=current_gallery)
424
-
425
- # 5. 打包结果
426
- if success_results:
427
- zip_name = f"Pack_{task_id}.zip"
428
- zip_path = os.path.join(task_dir, zip_name)
429
- with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
430
- for f in success_results:
431
- zf.write(f, os.path.basename(f))
432
 
433
- task_manager.log(task_id, f"📦 所有任务完成。打包成功: {zip_name}")
434
- task_manager.update_status(task_id, "completed", progress=100, result_zip=zip_path, gallery=success_results)
435
- else:
436
- task_manager.log(task_id, "❌ 所有任务均失败,无结果生成。")
437
- task_manager.update_status(task_id, "failed", progress=100)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
  except Exception as e:
440
- err_msg = traceback.format_exc()
441
- task_manager.log(task_id, f"💥 致命错误: {err_msg}")
442
  task_manager.update_status(task_id, "error")
443
 
444
- # ==========================================
445
- # 5. UI 交互层
446
- # ==========================================
447
 
448
-
449
-
450
-
451
- def submit_task_ui(images, video, prompt):
452
- if not images: return "❌ 未上传图片", None
453
- if not video: return "❌ 未上传视频", None
454
 
455
- # 创建任务
456
- task_desc = f"Batch_{len(images)}imgs"
457
- task_id, task_dir = task_manager.create_task(task_desc)
458
 
459
- # 启动后台线程
 
 
460
  t = threading.Thread(target=background_worker, args=(task_id, images, video, prompt))
461
  t.start()
462
 
463
- return f"✅ 任务已后台启动!ID: {task_id}\n请切换到【任务监控】标签查看进度。", task_id
464
-
465
- def refresh_task_list_ui():
466
- choices = task_manager.list_tasks()
467
- return gr.Dropdown(choices=choices, value=choices[0] if choices else None)
468
 
 
 
 
469
 
470
- def get_task_details_ui(task_id):
 
471
  info = task_manager.get_task_info(task_id)
472
- if not info:
473
- return "未选择任务", [], None, "状态: 未知"
474
 
475
- status_text = f"状态: {info['status']}"
476
- # 返回: 日志内容, Gallery列表, 下载文件, 状态标签
477
- return info['log'], info['gallery'], info['result_zip'], status_text
478
 
479
- # 🔥🔥 新增函数:处理清空储存的逻辑 🔥🔥
480
  def handle_clear_storage():
481
- success, msg = task_manager.clear_all_tasks()
482
- # 清空后,需要重置 UI 组件:清空下拉列表、清空日志、清空画廊、清空下载链接、重置状态
483
- return msg, gr.Dropdown(choices=[], value=None), "", [], None, "状态: 已清空"
484
-
485
-
486
-
487
-
488
-
489
-
490
-
491
-
492
-
493
-
494
-
495
-
496
 
 
497
 
498
-
499
-
500
-
501
- # ==========================================
502
- # 6. Gradio App
503
- # ==========================================
504
-
505
- with gr.Blocks(title="Kie AI 异步批量动作迁移") as demo:
506
- gr.Markdown("## ⚡️ Kie AI 批量动作迁移 - 异步后台版")
507
- gr.Markdown("提交任务后可关闭页面,任务会在后台继续运行。刷新页面后在“任务监控”中查看结果。")
508
-
509
  with gr.Tabs():
510
- # --- 提交页面 ---
511
  with gr.Tab("🚀 提交新任务"):
512
  with gr.Row():
513
  with gr.Column():
514
- img_input = gr.File(label="1. 上传图片 (多选)", file_count="multiple", type="filepath", height=200)
515
- video_input = gr.Video(label="2. 上传驱动视频", format="mp4", height=200)
516
- prompt_input = gr.Textbox(label="动作描述", value="The character matches the pose and motion of the video reference.", lines=2)
517
- submit_btn = gr.Button("开始后台任务", variant="primary")
518
  with gr.Column():
519
- submit_output = gr.Textbox(label="提交结果", lines=4)
520
- # 隐藏状态用于自动跳转
521
- new_task_id_state = gr.State()
522
 
523
- # --- 监控页面 ---
524
  with gr.Tab("📺 任务监控"):
525
  with gr.Row():
526
  with gr.Column(scale=1):
527
- refresh_btn = gr.Button("🔄 刷新任务列表")
528
- task_selector = gr.Dropdown(label="选择历史任务", choices=[], interactive=True)
529
- status_label = gr.Label("当前状态")
530
- download_btn = gr.File(label="📦 下载打包结果 (ZIP)")
531
 
532
- # 🔥🔥🔥 新增:清空按钮区域 🔥🔥🔥
533
- gr.Markdown("---") # 分割线
534
- clear_btn = gr.Button("🗑️ 危险:清空所有任务数据", variant="stop")
535
- clear_msg = gr.Label(label="��作结果") # 显示清空成功/失败的消息
536
-
537
- with gr.Column(scale=3):
538
- # 实时预览
539
- gallery_view = gr.Gallery(label="生成结果预览", columns=4, height="auto", object_fit="contain", interactive=False)
540
- # 日志
541
- log_view = gr.Textbox(label="运行日志", lines=15, max_lines=15, elem_id="log_box")
542
 
543
- # 自动刷新器 (每2秒刷新一次详情)
544
- timer = gr.Timer(2)
 
 
 
 
 
545
 
546
  # --- 事件绑定 ---
547
-
548
- # 1. 提交任务
549
  submit_btn.click(
550
- submit_task_ui,
551
- inputs=[img_input, video_input, prompt_input],
552
- outputs=[submit_output, new_task_id_state]
553
  )
554
-
555
- # 提交后自动选中新任务 (通过 js 或更新 dropdown)
556
- def auto_select_new(task_id):
557
- tasks = task_manager.list_tasks()
558
- return gr.Dropdown(choices=tasks, value=task_id)
559
-
560
- submit_btn.click(auto_select_new, inputs=[new_task_id_state], outputs=[task_selector])
561
 
562
- # 2. 刷列表
563
- refresh_btn.click(refresh_task_list_ui, outputs=[task_selector])
 
 
564
 
565
- # 3. 页面加载时刷新列表
566
- demo.load(refresh_task_list_ui, outputs=[task_selector])
567
-
568
-
569
-
570
 
 
 
571
 
572
-
573
-
574
-
575
-
576
-
577
-
578
-
579
-
580
-
581
-
582
-
583
-
584
-
585
-
586
-
587
- # 4. 选中任务或定时器触发 -> 更新详情
588
- task_selector.change(get_task_details_ui, inputs=[task_selector], outputs=[log_view, gallery_view, download_btn, status_label])
589
- timer.tick(get_task_details_ui, inputs=[task_selector], outputs=[log_view, gallery_view, download_btn, status_label])
590
 
591
- # 🔥🔥🔥 5. 绑定清空按钮事件 🔥🔥🔥
592
- # 点击按钮 -> 调用 handle_clear_storage -> 更新:消息、下拉列表、日志、画廊、下载文件、状态标签
593
- clear_btn.click(
594
- handle_clear_storage,
595
- outputs=[clear_msg, task_selector, log_view, gallery_view, download_btn, status_label]
596
- )
597
 
598
  if __name__ == "__main__":
599
- # 允许访问 tasks_data 目录以便显示图片
600
- demo.queue().launch(
601
- server_name="0.0.0.0",
602
- inbrowser=True,
603
- allowed_paths=[TASKS_ROOT_DIR, "tasks_data"]
604
- )
 
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)