fix: musetalk_server 60fps降帧+超时终止+health GFPGAN字段 #2006

Merged
auto-approve-bot merged 1 commits from fix/musetalk-server-bugs into develop 2026-09-21 17:24:14 +08:00
+195 -37
View File
@@ -2,6 +2,15 @@
部署在 RTX2060 本地,接收 gpu_worker.py 的推理请求,调用 MuseTalk 生成口型同步视频。
Bug 修复(2026-09-21):
- Bug1: 60fps降帧逻辑 — 输入视频 >30fps 时先降帧至 25fps 推理,推理后用
minterpolate MCI 插帧回原帧率,并记录 input_fps/inference_fps
- Bug2: 超时终止机制 — 推理线程改为 daemon + abort_event 机制,超时时 set event
让推理循环检测退出,同时 kill 所有活跃 ffmpeg 子进程,等线程退出后再释放锁和清理目录
- Bug3: /health 接口增加 gfpgan_loaded 和 gfpgan_load_error 字段
- Bonus: 动态超时计算(基础120s + 帧数*0.15s,上限1800s)
- Bonus: GFPGAN 增强异常时 log warning 而非静默跳过
#2000 关键修复:
- 集成真实 MuseTalk 推理(替换原有 stub 代码)
- 音频预处理:22050Hz MP3 → 16kHz mono 16bit WAV(MuseTalk 要求)
@@ -95,6 +104,15 @@ class Config:
inference_lock = threading.Lock()
current_task: dict = {"task_id": None, "process": None, "start_time": 0.0}
shutdown_event = threading.Event()
# 当前推理线程的终止信号和线程引用
current_abort_event: Optional[threading.Event] = None
current_thread: Optional[threading.Thread] = None
# 全局追踪正在运行的 ffmpeg 子进程(用于超时终止)
_active_ffmpeg_procs: list[subprocess.Popen] = []
_active_ffmpeg_lock = threading.Lock()
# GFPGAN 加载状态(供 /health 接口查询)
_gfpgan_loaded = False
_gfpgan_load_error: Optional[str] = None
# ── MuseTalk 模型懒加载 ─────────────────────────────────────────────
_muse_models = None
@@ -374,21 +392,33 @@ def _check_file_size(file, max_mb: int, label: str) -> Optional[str]:
def _run_ffmpeg(cmd: list, timeout: float = 120) -> subprocess.CompletedProcess:
"""运行 ffmpeg 命令,检查返回码和超时."""
"""运行 ffmpeg 命令,检查返回码和超时。进程会被注册到全局列表以便外部终止."""
proc = None
try:
result = subprocess.run(
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=True,
)
return result
except subprocess.CalledProcessError as exc:
stderr = exc.stderr.decode(errors="ignore") if exc.stderr else ""
raise RuntimeError(f"ffmpeg 失败 (code={exc.returncode}): {stderr[:500]}") from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"ffmpeg 超时(>{timeout}s)") from exc
with _active_ffmpeg_lock:
_active_ffmpeg_procs.append(proc)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
raise RuntimeError(f"ffmpeg 超时(>{timeout}s)")
if proc.returncode != 0:
stderr_text = stderr.decode(errors="ignore") if stderr else ""
raise RuntimeError(f"ffmpeg 失败 (code={proc.returncode}): {stderr_text[:500]}")
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
finally:
if proc is not None:
with _active_ffmpeg_lock:
try:
_active_ffmpeg_procs.remove(proc)
except ValueError:
pass
# ── MuseTalk 模型加载 ────────────────────────────────────────────────
@@ -401,7 +431,7 @@ def _load_musetalk_models():
加载到 GPU 后转为 FP16(如果配置开启)以节省显存。
RTX2060 6G 显存,FP16 大约需要 3-4GB。
"""
global _muse_models, _muse_models_loaded, _muse_load_error
global _muse_models, _muse_models_loaded, _muse_load_error, _gfpgan_loaded, _gfpgan_load_error
if _muse_models_loaded:
return _muse_models
@@ -500,13 +530,21 @@ def _load_musetalk_models():
gfpgan_model = gfpgan_model.half()
gfpgan_model = gfpgan_model.to(device)
del gfpgan_ckpt
_gfpgan_loaded = True
_gfpgan_load_error = None
logger.info("GFPGAN 加载完成 (FP16=%s)", Config.use_float16)
else:
_gfpgan_loaded = False
_gfpgan_load_error = f"模型文件不存在: {gfpgan_path}"
logger.warning("GFPGAN 模型不存在: %s,跳过人脸增强", gfpgan_path)
except Exception as e:
_gfpgan_loaded = False
_gfpgan_load_error = str(e)
logger.warning("GFPGAN 加载失败,跳过人脸增强: %s", e)
gfpgan_model = None
else:
_gfpgan_loaded = False
_gfpgan_load_error = "已通过环境变量禁用 (MUSE_USE_GFPGAN=0)"
logger.info("GFPGAN 已禁用 (MUSE_USE_GFPGAN=0)")
_muse_models = {
@@ -562,6 +600,7 @@ def _run_inference(
audio_path: Path,
output_path: Path,
bbox_shift: int = 0,
abort_event: threading.Event = None,
) -> None:
"""执行 MuseTalk 真实推理.
@@ -678,13 +717,39 @@ def _run_inference(
audio_processor.feature2chunks = _types.MethodType(_feature2chunks, audio_processor)
fps = _get_video_fps(video_path)
original_fps = fps
audio_duration = _get_media_duration(audio_path)
video_duration = _get_media_duration(video_path)
logger.info(
"MuseTalk 推理开始: video=%.2fs, audio=%.2fs, fps=%.1f, bbox_shift=%d",
"MuseTalk 推理开始: video=%.2fs, audio=%.2fs, input_fps=%.1f, bbox_shift=%d",
video_duration, audio_duration, fps, bbox_shift,
)
# ── Step 0: 高帧率视频降帧(>30fps → 25fps 推理,推理后再插帧回原帧率)──
inference_fps = fps
video_downsampled = False
if fps > 30:
inference_fps = 25.0
downsampled_video = video_path.parent / "input_25fps.mp4"
logger.info("检测到高帧率视频 %.1f fps,降帧至 %.1f fps 进行推理", fps, inference_fps)
try:
_run_ffmpeg([
"ffmpeg", "-y", "-v", "warning",
"-i", str(video_path),
"-r", str(inference_fps),
"-c:v", "libx264", "-preset", "veryfast",
"-crf", "18", "-pix_fmt", "yuv420p",
str(downsampled_video),
], timeout=120)
video_path = downsampled_video
video_downsampled = True
# 更新帧数和时长
total_input_frames_approx = int(audio_duration * inference_fps)
logger.info("降帧完成: input_fps=%.1f → inference_fps=%.1f", original_fps, inference_fps)
except Exception as e:
logger.warning("视频降帧失败,使用原始帧率 %.1f fps 推理: %s", fps, e)
inference_fps = fps
# ── Step 1: 音频预处理(关键修复:22050Hz MP3 → 16kHz mono WAV)──
audio_wav_path = video_path.parent / "audio_16k_mono.wav"
_preprocess_audio(audio_path, audio_wav_path, target_sr=16000)
@@ -703,7 +768,7 @@ def _run_inference(
logger.info("人脸检测完成,有效 bbox: %d/%d", sum(1 for c in coord_list if c is not coord_placeholder), total_frames)
# 使用 mirror indexing 循环帧和坐标(避免硬切跳变)
num_output_frames = int(audio_duration * fps)
num_output_frames = int(audio_duration * inference_fps)
if num_output_frames <= 0:
num_output_frames = total_frames
@@ -713,7 +778,7 @@ def _run_inference(
audio_array, _ = librosa.load(str(audio_wav_path), sr=16000, mono=True)
whisper_features = audio_processor.feature2chunks(
feature_array=audio_array,
fps=fps,
fps=inference_fps,
weight_dtype=(torch.float16 if Config.use_float16 else torch.float32),
batch_size=Config.batch_size,
)
@@ -769,6 +834,10 @@ def _run_inference(
total_batches = (video_num + bs - 1) // bs
for bi in tqdm(range(total_batches), desc="MuseTalk 推理"):
# 检查是否收到终止信号
if abort_event is not None and abort_event.is_set():
logger.warning("收到终止信号,中止推理 (batch %d/%d)", bi, total_batches)
raise InterruptedError("推理被外部终止")
whisper_batch = whisper_features[bi*bs:(bi+1)*bs]
if len(whisper_batch) == 0:
break
@@ -818,7 +887,7 @@ def _run_inference(
_ff_cmd = [
"ffmpeg", "-y", "-v", "warning",
"-f", "rawvideo", "-pix_fmt", "bgr24",
"-s", f"{frame_w}x{frame_h}", "-r", str(fps),
"-s", f"{frame_w}x{frame_h}", "-r", str(inference_fps),
"-i", "-",
"-vcodec", "libx264", "-preset", "veryfast",
"-vf", "format=yuv420p", "-crf", "18",
@@ -826,10 +895,17 @@ def _run_inference(
]
import subprocess as _sp
_ff_proc = _sp.Popen(_ff_cmd, stdin=_sp.PIPE, stdout=_sp.DEVNULL, stderr=_sp.PIPE)
# 注册 ffmpeg 进程到全局列表,以便超时终止
with _active_ffmpeg_lock:
_active_ffmpeg_procs.append(_ff_proc)
n_out = min(len(res_frame_list), num_output_frames)
try:
for i in tqdm(range(n_out), desc="合成帧"):
# 检查终止信号
if abort_event is not None and abort_event.is_set():
logger.warning("收到终止信号,中止合成 (frame %d/%d)", i, n_out)
raise InterruptedError("推理被外部终止")
cyc_i = i % len(coord_cycle)
x1, y1, x2, y2 = coord_cycle[cyc_i]
ori_frame = copy.deepcopy(frame_list_cycle[cyc_i])
@@ -863,8 +939,8 @@ def _run_inference(
res_frame_resized = cv2.resize(_out_bgr, (_fw, _fh),
interpolation=cv2.INTER_LANCZOS4)
del _face_t, _out, _out_bgr
except Exception:
pass
except Exception as _gfpgan_err:
logger.warning("GFPGAN 增强失败(帧 %d),使用原图: %s", i, _gfpgan_err)
# face parsing 融合
try:
@@ -882,27 +958,60 @@ def _run_inference(
_ff_proc.stdin.close()
_ff_ret = _ff_proc.wait(timeout=120)
# 从全局列表中移除已完成的 ffmpeg 进程
with _active_ffmpeg_lock:
try:
_active_ffmpeg_procs.remove(_ff_proc)
except ValueError:
pass
if _ff_ret != 0:
_ff_err = _ff_proc.stderr.read().decode(errors="ignore") if _ff_proc.stderr else ""
raise RuntimeError(f"ffmpeg编码失败(exit={_ff_ret}): {_ff_err[-300:]}")
except Exception:
try: _ff_proc.kill()
except Exception: pass
with _active_ffmpeg_lock:
try:
_active_ffmpeg_procs.remove(_ff_proc)
except ValueError:
pass
raise
shutil.copy2(str(silent_video_path), str(output_path))
# ── Step 8: 高帧率视频插帧还原(如输入 >30fps,从 25fps 插帧回原帧率)──
final_video = silent_video_path
if video_downsampled:
upscaled_video = video_path.parent / "output_upscaled.mp4"
logger.info("将推理结果从 %.1f fps 插帧还原至 %.1f fps", inference_fps, original_fps)
try:
_run_ffmpeg([
"ffmpeg", "-y", "-v", "warning",
"-i", str(silent_video_path),
"-vf", f"minterpolate=mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1:fps={int(original_fps)}",
"-c:v", "libx264", "-preset", "veryfast",
"-crf", "18", "-pix_fmt", "yuv420p",
str(upscaled_video),
], timeout=max(300, int(original_fps * 10)))
final_video = upscaled_video
logger.info("插帧还原完成: %.1f fps", original_fps)
except Exception as e:
logger.warning("插帧还原失败,使用 %.1f fps 结果: %s", inference_fps, e)
final_video = silent_video_path
shutil.copy2(str(final_video), str(output_path))
try:
torch.cuda.empty_cache()
if audio_wav_path.exists():
audio_wav_path.unlink()
if silent_video_path.exists() and str(silent_video_path) != str(output_path):
silent_video_path.unlink()
# 清理中间文件
for _tmp in [silent_video_path] + ([video_path] if video_downsampled else []):
if _tmp.exists() and str(_tmp) != str(output_path):
_tmp.unlink()
except Exception as e:
logger.warning("清理中间文件失败: %s", e)
logger.info("MuseTalk 推理完成: output=%s, duration=%.2fs",
output_path.name, _get_media_duration(output_path))
logger.info("MuseTalk 推理完成: output=%s, duration=%.2fs, input_fps=%.1f, inference_fps=%.1f",
output_path.name, _get_media_duration(output_path), original_fps, inference_fps)
# ── 路由 ──────────────────────────────────────────────────────────────
@@ -924,6 +1033,8 @@ def health():
"current_task": task_info,
"musetalk_loaded": _muse_models_loaded,
"musetalk_load_error": str(_muse_load_error) if _muse_load_error else None,
"gfpgan_loaded": _gfpgan_loaded,
"gfpgan_load_error": _gfpgan_load_error,
"timestamp": time.time(),
}
)
@@ -982,24 +1093,52 @@ def inference():
current_task["start_time"] = time.time()
current_task["process"] = "inference_thread" # 标记为运行中
# 在线程中运行推理(支持超时)
# 在线程中运行推理(支持超时 + 终止信号)
result_container = {"error": None}
abort_event = threading.Event()
global current_abort_event, current_thread
current_abort_event = abort_event
def inference_thread():
try:
_run_inference(video_path, audio_path, output_path, bbox_shift=bbox_shift)
_run_inference(video_path, audio_path, output_path, bbox_shift=bbox_shift, abort_event=abort_event)
except Exception as exc:
logger.exception("推理异常: %s", exc)
result_container["error"] = str(exc)
thread = threading.Thread(target=inference_thread)
thread = threading.Thread(target=inference_thread, daemon=True)
current_thread = thread
thread.start()
thread.join(timeout=Config.inference_timeout)
# 动态超时:基础 120s + 预估帧数×0.15s,上限 1800s
video_fps = _get_video_fps(video_path)
video_duration = _get_media_duration(video_path)
estimated_frames = int(video_duration * video_fps) if video_duration > 0 else 500
dynamic_timeout = min(max(Config.inference_timeout, 120 + estimated_frames * 0.15), 1800)
thread.join(timeout=dynamic_timeout)
if thread.is_alive():
# 超时,终止
logger.error("推理超时 (>%ds),终止任务 %s", Config.inference_timeout, task_id)
return jsonify({"error": f"推理超时(>{Config.inference_timeout}s)", "task_id": task_id}), 504
# 超时处理:发终止信号 + 杀 ffmpeg 子进程 + 等待线程退出
logger.error("推理超时 (>%ds),终止任务 %s", dynamic_timeout, task_id)
abort_event.set()
# 终止所有正在运行的 ffmpeg 子进程
with _active_ffmpeg_lock:
for proc in _active_ffmpeg_procs[:]:
try:
proc.kill()
proc.wait(timeout=5)
except Exception:
pass
_active_ffmpeg_procs.clear()
# 等待线程退出(daemon 线程会在主进程退出时自动终止,但这里给一定时间让它清理)
thread.join(timeout=10)
# 标记超时错误,由 finally 统一清理
result_container["error"] = f"推理超时(>{dynamic_timeout:.0f}s)"
result_container["timeout"] = True
if result_container.get("timeout"):
return jsonify({"error": result_container["error"], "task_id": task_id}), 504
if result_container["error"]:
logger.error("推理失败 task_id=%s: %s", task_id, result_container["error"])
@@ -1019,6 +1158,8 @@ def inference():
current_task["task_id"] = None
current_task["process"] = None
current_task["start_time"] = 0.0
current_abort_event = None
current_thread = None
# 清理临时文件
if video_path and video_path.parent.exists():
@@ -1032,20 +1173,35 @@ def inference():
@app.route("/cancel", methods=["POST"])
def cancel():
"""终止当前正在进行的推理任务."""
global current_abort_event, current_thread
if current_task["task_id"] is None:
return jsonify({"message": "当前无正在运行的任务"})
task_id = current_task["task_id"]
logger.info("收到取消请求,终止任务 %s", task_id)
# 终止推理进程(如果是 subprocess)
if current_task["process"] and current_task["process"] != "inference_thread":
try:
current_task["process"].terminate()
current_task["process"].wait(timeout=5)
logger.info("已终止推理进程")
except Exception as exc:
logger.warning("终止进程失败: %s", exc)
# 发送终止信号给推理线程
if current_abort_event is not None:
current_abort_event.set()
logger.info("已发送终止信号给推理线程")
# 终止所有正在运行的 ffmpeg 子进程
with _active_ffmpeg_lock:
for proc in _active_ffmpeg_procs[:]:
try:
proc.kill()
proc.wait(timeout=5)
logger.info("已终止 ffmpeg 子进程")
except Exception as exc:
logger.warning("终止 ffmpeg 子进程失败: %s", exc)
_active_ffmpeg_procs.clear()
# 等待推理线程退出(daemon 线程会在主进程退出时自动终止)
if current_thread is not None and current_thread.is_alive():
current_thread.join(timeout=10)
if current_thread.is_alive():
logger.warning("推理线程未能在 10s 内退出,将作为 daemon 线程自动终止")
# 清理临时文件
task_dir = Path(Config.temp_dir) / task_id
@@ -1060,6 +1216,8 @@ def cancel():
current_task["task_id"] = None
current_task["process"] = None
current_task["start_time"] = 0.0
current_abort_event = None
current_thread = None
return jsonify({"message": f"已取消任务 {task_id}"})