Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e2d1cd73e | |||
| 32473485d7 |
@@ -220,13 +220,4 @@ GPU_WORKER_TOKEN=
|
||||
# 单任务超时(秒),processing 超过此时长无任务心跳才回退 pending 或标记 failed
|
||||
# #1970:RTX2060 6G 推理 720p 长视频需 5 分钟以上,默认 900
|
||||
GPU_TASK_TIMEOUT_SECONDS=900
|
||||
# 是否启用 GPU 口型同步(开关)。开启后需同时有 Worker 在心跳窗口内(5分钟)才会走 GPU 路径;
|
||||
# 开关关闭 / 无可用 Worker / GPU 任务失败或超时 → 自动回退现有 MediaKit 云端 lipsync
|
||||
USE_GPU_LIPSYNC=false
|
||||
# 业务侧轮询 GPU 任务结果的间隔(秒)
|
||||
GPU_LIPSYNC_POLL_INTERVAL=5
|
||||
# 业务侧等待 GPU 任务总超时(秒);超时回退 MediaKit
|
||||
GPU_LIPSYNC_WAIT_TIMEOUT=1200
|
||||
# Worker 心跳新鲜度窗口(秒),last_heartbeat_at 在此窗口内视为在线
|
||||
GPU_WORKER_STALE_SECONDS=300
|
||||
|
||||
|
||||
@@ -325,58 +325,3 @@ class GpuLipsyncService:
|
||||
t.updated_at = now
|
||||
if stuck_tasks:
|
||||
self.db.flush()
|
||||
|
||||
# ── 业务侧辅助 ──────────────────────────────────────────────────
|
||||
|
||||
def has_available_worker(self) -> bool:
|
||||
"""判断是否有 Worker 在心跳新鲜窗口内可用."""
|
||||
stale_cutoff = datetime.now(UTC) - timedelta(seconds=self.settings.gpu_worker_stale_seconds)
|
||||
return (
|
||||
self.db.query(GpuWorkerModel).filter(GpuWorkerModel.last_heartbeat_at >= stale_cutoff).first() is not None
|
||||
)
|
||||
|
||||
def wait_for_result(
|
||||
self,
|
||||
task_id: str,
|
||||
timeout_seconds: Optional[int] = None,
|
||||
poll_interval: Optional[float] = None,
|
||||
) -> Optional[GpuLipsyncTaskModel]:
|
||||
"""同步轮询等待 GPU 任务完成。
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID(由 create_task 返回)
|
||||
timeout_seconds: 总超时,默认取 settings.gpu_lipsync_wait_timeout
|
||||
poll_interval: 轮询间隔秒,默认取 settings.gpu_lipsync_poll_interval
|
||||
|
||||
Returns:
|
||||
终态 task(status=done/failed);超时返回 None(此时调用方应回退 MediaKit)。
|
||||
等待期间会自动调用 _recover_timed_out_tasks 做超时回收。
|
||||
"""
|
||||
import time
|
||||
|
||||
timeout = timeout_seconds if timeout_seconds is not None else self.settings.gpu_lipsync_wait_timeout
|
||||
interval = poll_interval if poll_interval is not None else self.settings.gpu_lipsync_poll_interval
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
while True:
|
||||
now = datetime.now(UTC)
|
||||
# 顺手回收超时任务
|
||||
try:
|
||||
self._recover_timed_out_tasks(now)
|
||||
self.db.commit()
|
||||
except Exception as exc: # noqa: BLE001 - 回收失败不阻塞主流程
|
||||
logger.warning("wait_for_result 回收超时任务异常: %s", exc)
|
||||
self.db.rollback()
|
||||
|
||||
task = self.db.get(GpuLipsyncTaskModel, task_id)
|
||||
if task is None:
|
||||
return None
|
||||
if task.status == "done":
|
||||
return task
|
||||
if task.status == "failed":
|
||||
return task
|
||||
# pending/processing 继续等
|
||||
if time.monotonic() >= deadline:
|
||||
logger.warning("GPU 任务 %s 等待超时(%ds),回退 MediaKit", task_id, timeout)
|
||||
return None
|
||||
time.sleep(interval)
|
||||
|
||||
@@ -36,7 +36,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
from packages.config import get_api_settings
|
||||
from packages.domain.sentence_timings import (
|
||||
compute_sentence_timings,
|
||||
probe_audio_duration,
|
||||
@@ -64,7 +63,6 @@ class LipsyncService:
|
||||
self.client = client or get_mediakit_client()
|
||||
self._cosyvoice = cosyvoice_service
|
||||
self._voice_clone_repo = voice_clone_repo
|
||||
self.settings = get_api_settings()
|
||||
|
||||
def _get_cosyvoice(self):
|
||||
"""延迟获取 CosyVoiceService(与 tts 路由一致,含 OSS 预签名配置)."""
|
||||
@@ -217,52 +215,7 @@ class LipsyncService:
|
||||
if timings:
|
||||
job.sentence_timings = timings
|
||||
|
||||
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
|
||||
use_gpu = False
|
||||
if self.settings.use_gpu_lipsync:
|
||||
try:
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
gpu_svc = GpuLipsyncService(self.db)
|
||||
if gpu_svc.has_available_worker():
|
||||
use_gpu = True
|
||||
logger.info("[lipsync] 检测到可用 GPU Worker,优先走 MuseTalk 本地推理: job_id=%s", job.id)
|
||||
else:
|
||||
logger.info("[lipsync] GPU 开关已开但无可用 Worker(心跳过期),回退 MediaKit: job_id=%s", job.id)
|
||||
except Exception as exc:
|
||||
logger.warning("[lipsync] GPU 服务初始化失败,回退 MediaKit: job_id=%s err=%s", job.id, exc)
|
||||
|
||||
if use_gpu:
|
||||
try:
|
||||
gpu_task = self._submit_to_gpu(job=job, gpu_svc=gpu_svc)
|
||||
if gpu_task is not None:
|
||||
# GPU 任务完成:直接把结果写入 job,标为 completed
|
||||
job.mediakit_task_id = "" # GPU 路径不走 MediaKit
|
||||
job.status = STATUS_COMPLETED
|
||||
job.output_video_url = gpu_task.result_url
|
||||
job.output_duration = gpu_task.result_duration or 0.0
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
"[lipsync] GPU MuseTalk 推理完成: job_id=%s gpu_task=%s duration=%.2f",
|
||||
job.id,
|
||||
gpu_task.id,
|
||||
job.output_duration,
|
||||
)
|
||||
# 转存到持久 OSS 路径(GPU 结果已在 gpu-lipsync/results/ 下,直接签短链)
|
||||
return
|
||||
# wait_for_result 返回 None 表示超时/最终失败 → 继续走 MediaKit 兜底
|
||||
logger.warning("[lipsync] GPU 任务等待超时或失败,回退 MediaKit: job_id=%s", job.id)
|
||||
self.db.rollback() # 回滚可能的中间状态
|
||||
except Exception as exc:
|
||||
logger.exception("[lipsync] GPU 路径异常,回退 MediaKit: job_id=%s err=%s", job.id, exc)
|
||||
try:
|
||||
self.db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. 签名 URL 并提交 MediaKit(兜底路径)
|
||||
# 4. 签名 URL 并提交 MediaKit
|
||||
video_url = self._sign_media_url(job.video_url)
|
||||
signed_audio_url = self._sign_media_url(job.audio_url)
|
||||
job.audio_url = signed_audio_url
|
||||
@@ -291,51 +244,6 @@ class LipsyncService:
|
||||
self.db.commit()
|
||||
raise
|
||||
|
||||
# ── GPU MuseTalk 路径 ────────────────────────────────────────────────
|
||||
|
||||
def _submit_to_gpu(self, *, job, gpu_svc) -> Optional[object]:
|
||||
"""创建 GPU 任务并同步等待结果。
|
||||
|
||||
成功返回终态 task 对象(status=done);超时或 GPU 最终失败返回 None,
|
||||
调用方回退 MediaKit。
|
||||
|
||||
注意:job.video_url / job.audio_url 可能是:
|
||||
- 自家 OSS 存储 key(storage.is_own_url 判断,gpu_svc.create_task 内部
|
||||
get_download_url 会自动签预签名 URL 给 Worker)
|
||||
- 外部公网 URL(CosyVoice 临时链接等):poll 返回时原样透传给 Worker,
|
||||
Worker 可直接 GET 下载。
|
||||
"""
|
||||
# 创建 GPU 任务
|
||||
gpu_task = gpu_svc.create_task(
|
||||
video_url=job.video_url,
|
||||
audio_url=job.audio_url,
|
||||
lipsync_job_id=job.id,
|
||||
user_id=job.user_id,
|
||||
project_id=job.project_id,
|
||||
)
|
||||
logger.info(
|
||||
"[lipsync] 已创建 GPU 任务: job_id=%s gpu_task=%s",
|
||||
job.id,
|
||||
gpu_task.id,
|
||||
)
|
||||
# 同步等待 Worker 处理完成(轮询 DB)
|
||||
final_task = gpu_svc.wait_for_result(gpu_task.id)
|
||||
if final_task is None:
|
||||
logger.warning("[lipsync] GPU 任务等待超时,回退 MediaKit: gpu_task=%s", gpu_task.id)
|
||||
return None
|
||||
if final_task.status != "done":
|
||||
logger.warning(
|
||||
"[lipsync] GPU 任务失败: gpu_task=%s status=%s err=%s",
|
||||
gpu_task.id,
|
||||
final_task.status,
|
||||
final_task.error_msg,
|
||||
)
|
||||
return None
|
||||
# result_url 是 OSS 存储 key;签一个长有效期 URL 写回 job.output_video_url
|
||||
result_signed = self._sign_media_url(final_task.result_url)
|
||||
final_task.result_url = result_signed or final_task.result_url
|
||||
return final_task
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
|
||||
@@ -259,7 +259,3 @@ APIZERO_API_KEY=${APIZERO_API_KEY}
|
||||
# ==================== GPU MuseTalk Worker(反向轮询) ====================
|
||||
GPU_WORKER_TOKEN=${GPU_WORKER_TOKEN}
|
||||
GPU_TASK_TIMEOUT_SECONDS=900
|
||||
USE_GPU_LIPSYNC=false
|
||||
GPU_LIPSYNC_POLL_INTERVAL=5
|
||||
GPU_LIPSYNC_WAIT_TIMEOUT=1200
|
||||
GPU_WORKER_STALE_SECONDS=300
|
||||
|
||||
@@ -276,7 +276,3 @@ APIZERO_API_KEY=${APIZERO_API_KEY}
|
||||
# ==================== GPU MuseTalk Worker(反向轮询) ====================
|
||||
GPU_WORKER_TOKEN=${GPU_WORKER_TOKEN}
|
||||
GPU_TASK_TIMEOUT_SECONDS=900
|
||||
USE_GPU_LIPSYNC=true
|
||||
GPU_LIPSYNC_POLL_INTERVAL=5
|
||||
GPU_LIPSYNC_WAIT_TIMEOUT=1200
|
||||
GPU_WORKER_STALE_SECONDS=300
|
||||
|
||||
+148
-62
@@ -1,87 +1,148 @@
|
||||
# MuseTalk GPU Worker — 部署指南
|
||||
# MuseTalk GPU Worker 部署指南
|
||||
|
||||
本目录包含 RTX2060 本地电脑上运行的 GPU Worker 脚本。
|
||||
Worker 采用 **反向轮询模式**:主动向 SaaS API 拉取待处理的口型同步任务 → 调用本地 MuseTalk 推理 → 把结果视频回传到 SaaS。不需要内网穿透。
|
||||
本目录包含两个组件:
|
||||
|
||||
## 目录文件
|
||||
1. **gpu_worker.py**:反向轮询客户端,部署在 RTX2060 本地,轮询 SaaS API 拉取口型任务,调用本地 MuseTalk 服务推理,上传结果回 SaaS。
|
||||
2. **musetalk_server.py**:MuseTalk Flask HTTP 服务端,接收 gpu_worker.py 的推理请求,调用 MuseTalk 模型生成口型同步视频。
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `gpu_worker.py` | Worker 主程序(单文件,零项目代码依赖,仅依赖 `requests`) |
|
||||
| `requirements.txt` | Python 依赖(只有 `requests`) |
|
||||
| `xiaoxia-gpu-worker.service` | systemd 服务单元(开机自启、异常自动重启) |
|
||||
| `.env.example` | 环境变量样例,复制为 `.env` 后填入真实值 |
|
||||
---
|
||||
|
||||
## 一、环境准备
|
||||
|
||||
1. **Python 3.10+**(Windows 建议从 python.org 安装;Linux 自带)
|
||||
2. **本地 MuseTalk 服务** 已启动在 `http://127.0.0.1:7861`,health 接口返回 `{"status":"ok","free_vram_mb":...}`
|
||||
3. **ffmpeg**(可选,用于读取输出视频时长;未装则 duration 报 0,不影响功能)
|
||||
4. 网络能访问 staging / 生产 API(`curl https://staging-api.xiaoxiajianji.com/health` 应返回 `{"status":"healthy"}`)
|
||||
### 1.1 硬件要求
|
||||
|
||||
## 二、部署步骤(Linux,推荐 systemd)
|
||||
- GPU: NVIDIA RTX 2060 或更高(显存 ≥ 6GB)
|
||||
- CUDA: 11.8+
|
||||
- Python: 3.10+
|
||||
- ffmpeg: 需安装并加入 PATH
|
||||
|
||||
### 1.2 安装依赖
|
||||
|
||||
```bash
|
||||
# 1. 创建部署目录
|
||||
sudo mkdir -p /opt/xiaoxia-gpu-worker
|
||||
sudo chown $USER:$USER /opt/xiaoxia-gpu-worker
|
||||
cd /opt/xiaoxia-gpu-worker
|
||||
|
||||
# 2. 拷贝脚本和依赖
|
||||
cp /path/to/deploy/gpu_worker/{gpu_worker.py,requirements.txt,xiaoxia-gpu-worker.service,.env.example} .
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填入 API_BASE_URL 和 GPU_WORKER_TOKEN
|
||||
|
||||
# 3. 创建虚拟环境并安装依赖
|
||||
cd deploy/gpu_worker
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
|
||||
# 4. 前台先跑一次,确认日志正常
|
||||
./venv/bin/python gpu_worker.py
|
||||
# 看到 "MuseTalk 健康检查通过" 和 "注册/心跳" 成功即可 Ctrl+C 退出
|
||||
|
||||
# 5. 安装 systemd 服务
|
||||
sudo cp xiaoxia-gpu-worker.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now xiaoxia-gpu-worker
|
||||
|
||||
# 6. 查看日志
|
||||
sudo journalctl -u xiaoxia-gpu-worker -f
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 三、部署步骤(Windows,快速测试)
|
||||
---
|
||||
|
||||
```bat
|
||||
:: 创建虚拟环境
|
||||
python -m venv venv
|
||||
venv\Scripts\pip install -r requirements.txt
|
||||
## 二、MuseTalk 服务端部署(musetalk_server.py)
|
||||
|
||||
:: 复制并编辑 .env
|
||||
copy .env.example .env
|
||||
notepad .env
|
||||
### 2.1 配置环境变量
|
||||
|
||||
:: 运行
|
||||
venv\Scripts\python gpu_worker.py
|
||||
复制 `.env.example` 为 `.env`,修改配置:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
vim .env
|
||||
```
|
||||
|
||||
可在任务计划程序中添加开机启动项:程序选 `venv\Scripts\python.exe`,参数填 `gpu_worker.py`,起始目录填脚本所在目录。
|
||||
关键配置:
|
||||
|
||||
## 四、SaaS 侧配套配置
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `MUSE_PORT` | 监听端口 | `7861` |
|
||||
| `MUSE_INFERENCE_TIMEOUT` | 推理超时秒数 | `600` |
|
||||
| `MUSE_VIDEO_MAX_MB` | 视频上传大小限制 MB | `100` |
|
||||
| `MUSE_AUDIO_MAX_MB` | 音频上传大小限制 MB | `20` |
|
||||
| `MUSE_DEFAULT_FPS` | 视频 fps 兜底值 | `25.0` |
|
||||
| `MUSE_TEMP_DIR` | 临时文件目录 | `/tmp/musetalk_$$` |
|
||||
|
||||
SaaS 后端部署完成后需配置:
|
||||
### 2.2 启动服务
|
||||
|
||||
1. 服务端环境变量 `GPU_WORKER_TOKEN` 设为一个随机强 Token(和 Worker `.env` 中一致)
|
||||
2. 数据库已跑迁移 `081_add_gpu_lipsync_tasks`(自动随 API 启动的 alembic upgrade head 完成)
|
||||
3. OSS bucket 中 `gpu-lipsync/results/` 路径可写(默认 bucket 已配)
|
||||
```bash
|
||||
# 前台运行(调试用)
|
||||
python musetalk_server.py
|
||||
|
||||
## 五、验证联调
|
||||
# 后台运行(生产用 systemd)
|
||||
sudo systemctl start musetalk-server
|
||||
sudo systemctl enable musetalk-server
|
||||
```
|
||||
|
||||
1. Worker 启动后日志看到 `注册/心跳` 成功
|
||||
2. 后端调用 `GpuLipsyncService.create_task(video_url=..., audio_url=...)` 放入一条测试任务
|
||||
3. Worker 在 5 秒内拉到任务,下载 → 推理 → 上传 → 上报
|
||||
4. 后端 `GET /api/v1/gpu/lipsync/status/{task_id}` 返回 `status=done`,`result_url` 非空
|
||||
### 2.3 验证健康检查
|
||||
|
||||
## 六、故障排查
|
||||
```bash
|
||||
curl http://127.0.0.1:7861/health
|
||||
```
|
||||
|
||||
应返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"gpu": {
|
||||
"gpu_name": "NVIDIA GeForce RTX 2060",
|
||||
"memory_total_mb": 6144,
|
||||
"memory_used_mb": 1024,
|
||||
"memory_free_mb": 5120
|
||||
},
|
||||
"current_task": {
|
||||
"task_id": null,
|
||||
"running": false,
|
||||
"elapsed_seconds": 0.0
|
||||
},
|
||||
"timestamp": 1700000000.0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、GPU Worker 客户端部署(gpu_worker.py)
|
||||
|
||||
### 3.1 配置环境变量
|
||||
|
||||
复制 `.env.example` 为 `.env`,修改配置:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
vim .env
|
||||
```
|
||||
|
||||
关键配置:
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `API_BASE_URL` | SaaS API 基础 URL | `https://staging-api.xiaoxiajianji.com` |
|
||||
| `GPU_WORKER_TOKEN` | 长期 API Token(与服务端一致) | - |
|
||||
| `MUSE_TALK_URL` | 本地 MuseTalk 服务地址 | `http://127.0.0.1:7861` |
|
||||
| `POLL_INTERVAL` | 轮询间隔秒 | `5` |
|
||||
| `HEARTBEAT_INTERVAL` | 空闲心跳间隔秒 | `15` |
|
||||
| `REQUEST_TIMEOUT` | HTTP 请求超时秒 | `900` |
|
||||
| `TASK_MAX_RETRY` | 本地最大重试次数 | `1` |
|
||||
| `TASK_HEARTBEAT_INTERVAL` | 推理期间任务心跳间隔秒 | `30` |
|
||||
| `MIN_VIDEO_DURATION_SECONDS` | 最短输入视频时长秒 | `3` |
|
||||
|
||||
### 3.2 启动 Worker
|
||||
|
||||
```bash
|
||||
# 前台运行(调试用)
|
||||
python gpu_worker.py
|
||||
|
||||
# 后台运行(生产用 systemd)
|
||||
sudo systemctl start xiaoxia-gpu-worker
|
||||
sudo systemctl enable xiaoxia-gpu-worker
|
||||
```
|
||||
|
||||
### 3.3 验证启动日志
|
||||
|
||||
应看到:
|
||||
|
||||
```
|
||||
============================================================
|
||||
MuseTalk GPU Worker 启动
|
||||
worker_id = rtx2060-xxxx
|
||||
api_base = https://staging-api.xiaoxiajianji.com
|
||||
muse_talk = http://127.0.0.1:7861
|
||||
poll = 5.0s / heartbeat = 15.0s
|
||||
============================================================
|
||||
MuseTalk 健康检查通过: {...}
|
||||
注册/心跳成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、常见问题排查
|
||||
|
||||
| 现象 | 可能原因 / 排查 |
|
||||
|---|---|
|
||||
@@ -92,9 +153,34 @@ SaaS 后端部署完成后需配置:
|
||||
| 服务端看到任务回退到 pending 重试 | 任务心跳真正超时(默认 900s):Worker 进程崩溃/断网,或推理彻底卡死;正常长推理期间心跳线程每 30s 续期,不会回退 |
|
||||
| 日志 `MuseTalk 推理超时或连接失败` | 视频太长或显存不足;可临时调大 REQUEST_TIMEOUT(服务端 GPU_TASK_TIMEOUT_SECONDS 需同步调大),或限制输入视频时长 |
|
||||
| 日志 `视频过短(x.xxs < 3s)` | 输入视频不足 3s,MuseTalk 对短视频会 division by zero,已在本地直接上报失败;可用 MIN_VIDEO_DURATION_SECONDS 调整阈值 |
|
||||
| MuseTalk 服务端 503 `GPU 正在处理其他任务` | 并发请求被锁拒绝,等当前推理完成即可 |
|
||||
| MuseTalk 服务端 504 `推理超时` | 推理超过 MUSE_INFERENCE_TIMEOUT,客户端会调 /cancel 终止服务端任务 |
|
||||
|
||||
## 七、安全注意事项
|
||||
---
|
||||
|
||||
## 五、安全注意事项
|
||||
|
||||
- `.env` 包含长期 Token,文件权限设为 600(`chmod 600 .env`)
|
||||
- Token 泄露要立即在服务端更换 `GPU_WORKER_TOKEN` 并重启 Worker
|
||||
- Worker 只需要出站访问 SaaS API 和 OSS,不需要开放任何入站端口
|
||||
- MuseTalk 服务端只监听本地 127.0.0.1(或 0.0.0.0 但通过防火墙限制),不暴露到公网
|
||||
- 临时文件自动清理(推理完成/失败后),无需手动维护
|
||||
|
||||
---
|
||||
|
||||
## 六、工程改进记录(musetalk_server.py)
|
||||
|
||||
相比原 `worker.py`,修复了以下 8 个 bug:
|
||||
|
||||
1. **Flask 单线程阻塞**:`app.run(threaded=True)`,推理时 `/health` 仍可响应
|
||||
2. **fps=0 除零崩溃**:`_get_video_fps()` 兜底 `MUSE_DEFAULT_FPS`
|
||||
3. **ffmpeg 不检查返回码**:`subprocess.run(check=True)` + 超时检查,失败立即报错
|
||||
4. **无并发锁**:`threading.Lock` 控制并发,第二请求立即 503
|
||||
5. **无推理超时**:线程 join timeout,超时返回 504 并调 `/cancel`
|
||||
6. **结果文件不清理**:推理完成/失败后自动删除临时目录
|
||||
7. **无人脸检测兜底**:MuseTalk 推理内部处理(TODO: 可在 `_run_inference` 前置检查)
|
||||
8. **上传无大小限制**:`_check_file_size()` 校验,超限返回 413
|
||||
|
||||
新增:
|
||||
- `/cancel` 端点:终止当前推理任务,清理临时文件
|
||||
- `/health` 端点:返回 GPU 显存信息和当前任务状态
|
||||
|
||||
@@ -229,12 +229,26 @@ def _call_musetalk(video_path: Path, audio_path: Path, out_path: Path) -> tuple[
|
||||
duration = _probe_duration(out_path)
|
||||
return True, duration, "", False
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
|
||||
# 瞬时网络/超时错误,允许本地重试 1 次
|
||||
# 瞬时网络/超时错误,允许本地重试 1 次;同时调 /cancel 让服务端终止僵尸推理
|
||||
_cancel_musetalk()
|
||||
return False, 0.0, f"MuseTalk 推理超时或连接失败(>{Config.request_timeout}s)", True
|
||||
except Exception as exc:
|
||||
return False, 0.0, f"MuseTalk 调用异常: {exc}", False
|
||||
|
||||
|
||||
def _cancel_musetalk() -> None:
|
||||
"""调 MuseTalk /cancel 端点终止服务端僵尸推理进程,避免超时后任务还在跑占显存."""
|
||||
try:
|
||||
r = requests.post(f"{Config.muse_talk_url}/cancel", timeout=10)
|
||||
if r.status_code == 200:
|
||||
logger.info("已调 MuseTalk /cancel,服务端终止推理")
|
||||
else:
|
||||
logger.warning("MuseTalk /cancel 返回 %d: %s", r.status_code, r.text[:200])
|
||||
except Exception as exc:
|
||||
# /cancel 失败不应影响主流程上报
|
||||
logger.warning("调 MuseTalk /cancel 异常(忽略): %s", exc)
|
||||
|
||||
|
||||
def _probe_duration(path: Path) -> float:
|
||||
"""用 ffprobe 读视频时长(若系统装了 ffmpeg);否则返回 0."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"""MuseTalk Flask HTTP 服务 — 反向轮询架构的服务端部分.
|
||||
|
||||
部署在 RTX2060 本地,接收 gpu_worker.py 的推理请求,调用 MuseTalk 生成口型同步视频。
|
||||
本文件修复了原 worker.py 的 8 个工程 bug,并新增 /cancel 端点。
|
||||
|
||||
环境变量:
|
||||
MUSE_PORT 监听端口,默认 7861
|
||||
MUSE_MAX_CONCURRENT 最大并发推理数,默认 1(GPU 一次只能处理一个)
|
||||
MUSE_INFERENCE_TIMEOUT 推理超时秒数,默认 600
|
||||
MUSE_VIDEO_MAX_MB 视频上传大小限制 MB,默认 100
|
||||
MUSE_AUDIO_MAX_MB 音频上传大小限制 MB,默认 20
|
||||
MUSE_DEFAULT_FPS 视频 fps 兜底值,默认 25.0
|
||||
MUSE_TEMP_DIR 临时文件目录,默认 /tmp/musetalk_$$
|
||||
|
||||
接口:
|
||||
GET /health 健康检查 + GPU 显存信息
|
||||
POST /inference 推理请求(multipart: video + audio)
|
||||
POST /cancel 终止当前推理任务
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from flask import Flask, jsonify, request, send_file
|
||||
|
||||
# ── 日志 ──────────────────────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("musetalk-server")
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
v = os.environ.get(name, default)
|
||||
return v.strip() if isinstance(v, str) else default
|
||||
|
||||
|
||||
class Config:
|
||||
port: int = int(_env("MUSE_PORT", "7861"))
|
||||
max_concurrent: int = int(_env("MUSE_MAX_CONCURRENT", "1"))
|
||||
inference_timeout: float = float(_env("MUSE_INFERENCE_TIMEOUT", "600"))
|
||||
video_max_mb: int = int(_env("MUSE_VIDEO_MAX_MB", "100"))
|
||||
audio_max_mb: int = int(_env("MUSE_AUDIO_MAX_MB", "20"))
|
||||
default_fps: float = float(_env("MUSE_DEFAULT_FPS", "25.0"))
|
||||
temp_dir: str = _env("MUSE_TEMP_DIR", f"/tmp/musetalk_{os.getpid()}")
|
||||
|
||||
|
||||
# ── 全局状态 ──────────────────────────────────────────────────────────
|
||||
inference_lock = threading.Lock()
|
||||
current_task: dict = {"task_id": None, "process": None, "start_time": 0.0}
|
||||
shutdown_event = threading.Event()
|
||||
|
||||
# ── Flask App ─────────────────────────────────────────────────────────
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _cleanup_temp_dir():
|
||||
"""退出时清理临时目录."""
|
||||
if os.path.exists(Config.temp_dir):
|
||||
try:
|
||||
shutil.rmtree(Config.temp_dir)
|
||||
logger.info("已清理临时目录: %s", Config.temp_dir)
|
||||
except Exception as exc:
|
||||
logger.warning("清理临时目录失败: %s", exc)
|
||||
|
||||
|
||||
atexit.register(_cleanup_temp_dir)
|
||||
|
||||
|
||||
def _signal_handler(signum, frame):
|
||||
"""优雅退出."""
|
||||
logger.info("收到信号 %s,准备退出...", signum)
|
||||
shutdown_event.set()
|
||||
if current_task["process"]:
|
||||
logger.info("终止正在进行的推理进程...")
|
||||
try:
|
||||
current_task["process"].terminate()
|
||||
current_task["process"].wait(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
_cleanup_temp_dir()
|
||||
exit(0)
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_gpu_info() -> dict:
|
||||
"""获取 GPU 显存信息(通过 nvidia-smi)."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=name,memory.total,memory.used,memory.free",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
)
|
||||
parts = out.decode().strip().split(",")
|
||||
if len(parts) >= 4:
|
||||
return {
|
||||
"gpu_name": parts[0].strip(),
|
||||
"memory_total_mb": int(parts[1].strip()),
|
||||
"memory_used_mb": int(parts[2].strip()),
|
||||
"memory_free_mb": int(parts[3].strip()),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("nvidia-smi 失败: %s", exc)
|
||||
return {"gpu_name": "unknown", "memory_total_mb": 0, "memory_used_mb": 0, "memory_free_mb": 0}
|
||||
|
||||
|
||||
def _get_video_fps(video_path: Path) -> float:
|
||||
"""用 ffprobe 读视频帧率,失败或为 0 时返回 default_fps."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=r_frame_rate",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(video_path),
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
)
|
||||
fps_str = out.decode().strip()
|
||||
if "/" in fps_str:
|
||||
num, den = fps_str.split("/")
|
||||
fps = float(num) / float(den) if float(den) != 0 else 0.0
|
||||
else:
|
||||
fps = float(fps_str) if fps_str else 0.0
|
||||
return fps if fps > 0 else Config.default_fps
|
||||
except Exception as exc:
|
||||
logger.warning("ffprobe 读 fps 失败: %s,使用默认 %.1f", exc, Config.default_fps)
|
||||
return Config.default_fps
|
||||
|
||||
|
||||
def _check_file_size(file, max_mb: int, label: str) -> Optional[str]:
|
||||
"""检查文件大小,超限返回错误信息,否则返回 None."""
|
||||
file.seek(0, 2)
|
||||
size = file.tell()
|
||||
file.seek(0)
|
||||
max_bytes = max_mb * 1024 * 1024
|
||||
if size > max_bytes:
|
||||
return f"{label} 文件大小 {size / (1024*1024):.1f}MB 超过限制 {max_mb}MB"
|
||||
if size == 0:
|
||||
return f"{label} 文件为空"
|
||||
return None
|
||||
|
||||
|
||||
def _run_ffmpeg(cmd: list, timeout: float = 120) -> subprocess.CompletedProcess:
|
||||
"""运行 ffmpeg 命令,检查返回码和超时."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
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
|
||||
|
||||
|
||||
def _run_inference(video_path: Path, audio_path: Path, output_path: Path) -> None:
|
||||
"""执行 MuseTalk 推理(可被子线程和测试独立调用).
|
||||
|
||||
实际部署时替换为 MuseTalk 真实推理逻辑。
|
||||
此处为示例实现:提取帧 → 合并音视频。
|
||||
"""
|
||||
fps = _get_video_fps(video_path)
|
||||
logger.info("视频 fps: %.2f", fps)
|
||||
|
||||
frames_dir = video_path.parent / "frames"
|
||||
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||
_run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-r",
|
||||
str(fps),
|
||||
str(frames_dir / "frame_%05d.png"),
|
||||
],
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
frame_files = sorted(frames_dir.glob("*.png"))
|
||||
if not frame_files:
|
||||
raise RuntimeError("未从视频中提取到帧")
|
||||
|
||||
# TODO: 替换为 MuseTalk 实际推理逻辑
|
||||
logger.warning("使用示例推理逻辑,未实际调用 MuseTalk 模型")
|
||||
|
||||
_run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-shortest",
|
||||
str(output_path),
|
||||
],
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size < 1024:
|
||||
raise RuntimeError("推理产物不存在或过小")
|
||||
|
||||
|
||||
# ── 路由 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""健康检查 + GPU 显存信息."""
|
||||
gpu_info = _get_gpu_info()
|
||||
task_info = {
|
||||
"task_id": current_task["task_id"],
|
||||
"running": current_task["process"] is not None,
|
||||
"elapsed_seconds": time.time() - current_task["start_time"] if current_task["start_time"] else 0.0,
|
||||
}
|
||||
return jsonify(
|
||||
{
|
||||
"status": "healthy",
|
||||
"gpu": gpu_info,
|
||||
"current_task": task_info,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/inference", methods=["POST"])
|
||||
def inference():
|
||||
"""推理请求:multipart form 包含 video 和 audio 文件."""
|
||||
# 并发控制:检查锁
|
||||
if not inference_lock.acquire(blocking=False):
|
||||
return jsonify({"error": "GPU 正在处理其他任务,请稍后重试", "status": "busy"}), 503
|
||||
|
||||
task_id = None
|
||||
video_path = None
|
||||
audio_path = None
|
||||
output_path = None
|
||||
|
||||
try:
|
||||
# 解析参数
|
||||
if "video" not in request.files or "audio" not in request.files:
|
||||
return jsonify({"error": "缺少 video 或 audio 文件"}), 400
|
||||
|
||||
video_file = request.files["video"]
|
||||
audio_file = request.files["audio"]
|
||||
task_id = request.form.get("task_id", f"task_{int(time.time())}")
|
||||
|
||||
# 文件大小检查
|
||||
err = _check_file_size(video_file, Config.video_max_mb, "视频")
|
||||
if err:
|
||||
return jsonify({"error": err}), 413
|
||||
err = _check_file_size(audio_file, Config.audio_max_mb, "音频")
|
||||
if err:
|
||||
return jsonify({"error": err}), 413
|
||||
|
||||
# 保存到临时目录
|
||||
task_dir = Path(Config.temp_dir) / task_id
|
||||
task_dir.mkdir(parents=True, exist_ok=True)
|
||||
video_path = task_dir / "input.mp4"
|
||||
audio_path = task_dir / "input_audio.wav"
|
||||
output_path = task_dir / "output.mp4"
|
||||
|
||||
video_file.save(str(video_path))
|
||||
audio_file.save(str(audio_path))
|
||||
|
||||
logger.info("开始推理 task_id=%s, video=%s, audio=%s", task_id, video_path.name, audio_path.name)
|
||||
|
||||
# 更新当前任务信息
|
||||
current_task["task_id"] = task_id
|
||||
current_task["start_time"] = time.time()
|
||||
|
||||
# 启动推理进程(用 subprocess 包装,便于超时终止)
|
||||
# 此处直接调用推理函数,实际可改为 subprocess 调用外部脚本
|
||||
current_task["process"] = "inference_thread" # 标记为运行中
|
||||
|
||||
# 在线程中运行推理(支持超时)
|
||||
result_container = {"error": None}
|
||||
|
||||
def inference_thread():
|
||||
try:
|
||||
_run_inference(video_path, audio_path, output_path)
|
||||
except Exception as exc:
|
||||
result_container["error"] = str(exc)
|
||||
|
||||
thread = threading.Thread(target=inference_thread)
|
||||
thread.start()
|
||||
thread.join(timeout=Config.inference_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
|
||||
|
||||
if result_container["error"]:
|
||||
logger.error("推理失败 task_id=%s: %s", task_id, result_container["error"])
|
||||
return jsonify({"error": result_container["error"], "task_id": task_id}), 500
|
||||
|
||||
# 返回结果文件
|
||||
logger.info("推理完成 task_id=%s, output=%s", task_id, output_path)
|
||||
return send_file(str(output_path), mimetype="video/mp4", as_attachment=True, download_name=f"{task_id}.mp4")
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("推理异常: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
finally:
|
||||
# 释放锁,清理当前任务信息
|
||||
inference_lock.release()
|
||||
current_task["task_id"] = None
|
||||
current_task["process"] = None
|
||||
current_task["start_time"] = 0.0
|
||||
|
||||
# 清理临时文件
|
||||
if video_path and video_path.parent.exists():
|
||||
try:
|
||||
shutil.rmtree(video_path.parent)
|
||||
logger.info("已清理临时目录: %s", video_path.parent)
|
||||
except Exception as exc:
|
||||
logger.warning("清理临时目录失败: %s", exc)
|
||||
|
||||
|
||||
@app.route("/cancel", methods=["POST"])
|
||||
def cancel():
|
||||
"""终止当前正在进行的推理任务."""
|
||||
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)
|
||||
|
||||
# 清理临时文件
|
||||
task_dir = Path(Config.temp_dir) / task_id
|
||||
if task_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(task_dir)
|
||||
logger.info("已清理临时目录: %s", task_dir)
|
||||
except Exception as exc:
|
||||
logger.warning("清理临时目录失败: %s", exc)
|
||||
|
||||
# 重置当前任务
|
||||
current_task["task_id"] = None
|
||||
current_task["process"] = None
|
||||
current_task["start_time"] = 0.0
|
||||
|
||||
return jsonify({"message": f"已取消任务 {task_id}"})
|
||||
|
||||
|
||||
# ── 主入口 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main():
|
||||
"""启动 Flask 服务."""
|
||||
# 创建临时目录
|
||||
Path(Config.temp_dir).mkdir(parents=True, exist_ok=True)
|
||||
logger.info("临时目录: %s", Config.temp_dir)
|
||||
|
||||
# 打印配置
|
||||
logger.info("=" * 60)
|
||||
logger.info("MuseTalk Flask Server 启动")
|
||||
logger.info(" 端口: %d", Config.port)
|
||||
logger.info(" 最大并发: %d", Config.max_concurrent)
|
||||
logger.info(" 推理超时: %.0fs", Config.inference_timeout)
|
||||
logger.info(" 视频大小限制: %dMB", Config.video_max_mb)
|
||||
logger.info(" 音频大小限制: %dMB", Config.audio_max_mb)
|
||||
logger.info(" 默认 fps: %.1f", Config.default_fps)
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 检查 GPU
|
||||
gpu_info = _get_gpu_info()
|
||||
logger.info("GPU 信息: %s", gpu_info)
|
||||
|
||||
# 启动 Flask(threaded=True 处理并发请求)
|
||||
app.run(host="0.0.0.0", port=Config.port, threaded=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -93,15 +93,6 @@ class SharedSettings(BaseSettings):
|
||||
gpu_result_url_expires: int = 3600
|
||||
# 输入预签名 URL 有效期(秒,需留出 Worker 下载时间)
|
||||
gpu_input_url_expires: int = 3600
|
||||
# 业务侧是否启用 GPU 口型同步(开关);关或无可用 Worker 时回退 MediaKit 云端
|
||||
use_gpu_lipsync: bool = False
|
||||
# 业务侧轮询 GPU 任务结果的间隔(秒)
|
||||
gpu_lipsync_poll_interval: float = 5.0
|
||||
# 业务侧等待 GPU 任务结果的总超时(秒);超时后回退 MediaKit。
|
||||
# 应小于等于 gpu_task_timeout_seconds(默认900s)+ 冗余,留足 Worker 下载/上传时间。
|
||||
gpu_lipsync_wait_timeout: int = 1200
|
||||
# 判断 Worker 可用的心跳新鲜度窗口(秒)—— last_heartbeat_at 在窗口内视为在线
|
||||
gpu_worker_stale_seconds: int = 300
|
||||
|
||||
@property
|
||||
def effective_database_url(self) -> str:
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""#1970 MuseTalk Flask 服务端 8 项工程 bug 修复单测.
|
||||
|
||||
覆盖 deploy/gpu_worker/musetalk_server.py(独立部署脚本,按文件路径动态加载):
|
||||
1. threaded=True 启动,/health 在推理阻塞时仍可达
|
||||
2. fps 兜底:ffprobe 返回 0 或失败时使用 default_fps
|
||||
3. ffmpeg 走 subprocess.run(check=True),失败抛 RuntimeError
|
||||
4. 并发锁:推理期间第二请求立即 503
|
||||
5. 推理超时:超过 MUSE_INFERENCE_TIMEOUT 返回 504
|
||||
6. 结果文件清理:临时目录在请求结束(成功/失败)后删除
|
||||
7. 文件大小限制:超过限制返回 413,空文件返回 400
|
||||
8. /cancel 端点:终止当前推理,清理临时文件
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
# 检查 Flask 是否可用(CI 环境可能没装)
|
||||
try:
|
||||
import flask # noqa: F401
|
||||
|
||||
HAS_FLASK = True
|
||||
except ImportError:
|
||||
HAS_FLASK = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_FLASK, reason="Flask 未安装(gpu_worker 独立部署依赖)")
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVER_PATH = ROOT / "deploy" / "gpu_worker" / "musetalk_server.py"
|
||||
|
||||
|
||||
def _load_server_module(name: str = "musetalk_server_test"):
|
||||
"""加载 musetalk_server.py 为独立模块."""
|
||||
# 避免重复注册
|
||||
if name in sys.modules:
|
||||
del sys.modules[name]
|
||||
spec = importlib.util.spec_from_file_location(name, SERVER_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(tmp_path, monkeypatch):
|
||||
"""加载一个干净的 musetalk_server 模块,使用独立临时目录和端口."""
|
||||
if not HAS_FLASK:
|
||||
pytest.skip("Flask 未安装(gpu_worker 独立部署依赖)")
|
||||
|
||||
monkeypatch.setenv("MUSE_TEMP_DIR", str(tmp_path / "musetalk_temp"))
|
||||
monkeypatch.setenv("MUSE_PORT", "0")
|
||||
monkeypatch.setenv("MUSE_INFERENCE_TIMEOUT", "2")
|
||||
monkeypatch.setenv("MUSE_VIDEO_MAX_MB", "1")
|
||||
monkeypatch.setenv("MUSE_AUDIO_MAX_MB", "1")
|
||||
monkeypatch.setenv("MUSE_DEFAULT_FPS", "25.0")
|
||||
|
||||
mod_name = f"musetalk_server_test_{os.getpid()}_{id(tmp_path)}"
|
||||
mod = _load_server_module(mod_name)
|
||||
|
||||
# 确保配置已更新
|
||||
mod.Config.temp_dir = str(tmp_path / "musetalk_temp")
|
||||
mod.Config.inference_timeout = 2.0
|
||||
mod.Config.video_max_mb = 1
|
||||
mod.Config.audio_max_mb = 1
|
||||
mod.Config.default_fps = 25.0
|
||||
|
||||
Path(mod.Config.temp_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 重置全局状态
|
||||
mod.inference_lock = threading.Lock()
|
||||
mod.current_task = {"task_id": None, "process": None, "start_time": 0.0}
|
||||
|
||||
return mod
|
||||
|
||||
|
||||
# ── 1. Flask threaded=True ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_flask_run_uses_threaded(server):
|
||||
"""验证 app.run 调用时 threaded=True."""
|
||||
with mock.patch.object(server.app, "run") as mock_run:
|
||||
server.main()
|
||||
mock_run.assert_called_once()
|
||||
call_kwargs = mock_run.call_args
|
||||
assert call_kwargs.kwargs.get("threaded") is True
|
||||
|
||||
|
||||
# ── 2. fps=0 兜底 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_video_fps_fallback_on_zero(server, tmp_path):
|
||||
"""ffprobe 返回 0/1 时兜底为 default_fps."""
|
||||
fake_video = tmp_path / "fake.mp4"
|
||||
fake_video.write_bytes(b"fake")
|
||||
with mock.patch("subprocess.check_output", return_value=b"0/1"):
|
||||
fps = server._get_video_fps(fake_video)
|
||||
assert fps == 25.0
|
||||
|
||||
|
||||
def test_get_video_fps_normal(server, tmp_path):
|
||||
"""正常 fps 解析."""
|
||||
fake_video = tmp_path / "fake.mp4"
|
||||
fake_video.write_bytes(b"fake")
|
||||
with mock.patch("subprocess.check_output", return_value=b"30/1"):
|
||||
fps = server._get_video_fps(fake_video)
|
||||
assert abs(fps - 30.0) < 0.01
|
||||
|
||||
|
||||
def test_get_video_fps_exception_fallback(server, tmp_path):
|
||||
"""ffprobe 异常时兜底 default_fps."""
|
||||
fake_video = tmp_path / "fake.mp4"
|
||||
fake_video.write_bytes(b"fake")
|
||||
with mock.patch("subprocess.check_output", side_effect=Exception("no ffprobe")):
|
||||
fps = server._get_video_fps(fake_video)
|
||||
assert fps == 25.0
|
||||
|
||||
|
||||
# ── 3. ffmpeg 错误检查 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_run_ffmpeg_raises_on_nonzero_exit(server):
|
||||
"""ffmpeg 返回非零应抛 RuntimeError."""
|
||||
import subprocess
|
||||
|
||||
with mock.patch(
|
||||
"subprocess.run",
|
||||
side_effect=subprocess.CalledProcessError(1, "ffmpeg", stderr=b"decode error"),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="ffmpeg 失败"):
|
||||
server._run_ffmpeg(["ffmpeg", "-i", "in", "out"])
|
||||
|
||||
|
||||
def test_run_ffmpeg_raises_on_timeout(server):
|
||||
"""ffmpeg 超时应抛 RuntimeError."""
|
||||
import subprocess
|
||||
|
||||
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("ffmpeg", 10)):
|
||||
with pytest.raises(RuntimeError, match="ffmpeg 超时"):
|
||||
server._run_ffmpeg(["ffmpeg", "-i", "in", "out"], timeout=10)
|
||||
|
||||
|
||||
# ── 4. 并发锁 503 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_inference_returns_503_when_busy(server):
|
||||
"""推理期间第二请求立即 503."""
|
||||
server.inference_lock.acquire()
|
||||
server.current_task["task_id"] = "task-busy"
|
||||
server.current_task["start_time"] = time.time()
|
||||
|
||||
try:
|
||||
with server.app.test_client() as c:
|
||||
resp = c.post(
|
||||
"/inference",
|
||||
data={
|
||||
"video": (io.BytesIO(b"v" * 100), "v.mp4"),
|
||||
"audio": (io.BytesIO(b"a" * 100), "a.wav"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert resp.get_json()["status"] == "busy"
|
||||
finally:
|
||||
server.inference_lock.release()
|
||||
server.current_task = {"task_id": None, "process": None, "start_time": 0.0}
|
||||
|
||||
|
||||
# ── 5. 推理超时 504 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_inference_timeout_returns_504(server):
|
||||
"""推理超时返回 504."""
|
||||
|
||||
def slow_inference(*args, **kwargs):
|
||||
time.sleep(10) # 远超 2s 超时
|
||||
|
||||
with mock.patch.object(server, "_run_inference", side_effect=slow_inference):
|
||||
with server.app.test_client() as c:
|
||||
resp = c.post(
|
||||
"/inference",
|
||||
data={
|
||||
"video": (io.BytesIO(b"v" * 100), "v.mp4"),
|
||||
"audio": (io.BytesIO(b"a" * 100), "a.wav"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 504
|
||||
assert "超时" in resp.get_json()["error"]
|
||||
|
||||
|
||||
# ── 6. 临时文件清理 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_temp_files_cleaned_after_success(server, tmp_path):
|
||||
"""推理成功后临时目录被清理."""
|
||||
|
||||
def fake_inference(video_path, audio_path, output_path):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"v" * 2048)
|
||||
|
||||
with mock.patch.object(server, "_run_inference", side_effect=fake_inference):
|
||||
with server.app.test_client() as c:
|
||||
resp = c.post(
|
||||
"/inference",
|
||||
data={
|
||||
"video": (io.BytesIO(b"v" * 100), "v.mp4"),
|
||||
"audio": (io.BytesIO(b"a" * 100), "a.wav"),
|
||||
"task_id": "task-cleanup-ok",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
# send_file 返回 200 或推理异常 500
|
||||
assert resp.status_code in (200, 500)
|
||||
task_dir = Path(server.Config.temp_dir) / "task-cleanup-ok"
|
||||
assert not task_dir.exists(), f"临时目录 {task_dir} 应被清理"
|
||||
|
||||
|
||||
def test_temp_files_cleaned_after_failure(server, tmp_path):
|
||||
"""推理失败后临时目录也被清理."""
|
||||
|
||||
def failing_inference(*args, **kwargs):
|
||||
raise RuntimeError("MuseTalk crash")
|
||||
|
||||
with mock.patch.object(server, "_run_inference", side_effect=failing_inference):
|
||||
with server.app.test_client() as c:
|
||||
resp = c.post(
|
||||
"/inference",
|
||||
data={
|
||||
"video": (io.BytesIO(b"v" * 100), "v.mp4"),
|
||||
"audio": (io.BytesIO(b"a" * 100), "a.wav"),
|
||||
"task_id": "task-cleanup-fail",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 500
|
||||
task_dir = Path(server.Config.temp_dir) / "task-cleanup-fail"
|
||||
assert not task_dir.exists()
|
||||
|
||||
|
||||
# ── 7. 文件大小限制 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_oversize_video_returns_413(server):
|
||||
"""视频超过大小限制返回 413."""
|
||||
big_video = b"v" * (2 * 1024 * 1024) # 2MB > 1MB limit
|
||||
with server.app.test_client() as c:
|
||||
resp = c.post(
|
||||
"/inference",
|
||||
data={
|
||||
"video": (io.BytesIO(big_video), "v.mp4"),
|
||||
"audio": (io.BytesIO(b"a" * 100), "a.wav"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
assert "超过限制" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_empty_file_returns_400(server):
|
||||
"""空文件返回 400."""
|
||||
with server.app.test_client() as c:
|
||||
resp = c.post(
|
||||
"/inference",
|
||||
data={
|
||||
"video": (io.BytesIO(b""), "v.mp4"),
|
||||
"audio": (io.BytesIO(b"a" * 100), "a.wav"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code in (400, 413)
|
||||
assert "为空" in resp.get_json().get("error", "") or "超过限制" in resp.get_json().get("error", "")
|
||||
|
||||
|
||||
def test_missing_file_returns_400(server):
|
||||
"""缺少必要文件返回 400."""
|
||||
with server.app.test_client() as c:
|
||||
resp = c.post(
|
||||
"/inference",
|
||||
data={"video": (io.BytesIO(b"v" * 100), "v.mp4")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── 8. /cancel 端点 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cancel_no_running_task(server):
|
||||
"""无任务时 /cancel 返回提示."""
|
||||
with server.app.test_client() as c:
|
||||
resp = c.post("/cancel")
|
||||
assert resp.status_code == 200
|
||||
assert "无正在运行" in resp.get_json()["message"]
|
||||
|
||||
|
||||
def test_cancel_terminates_running_task(server, tmp_path):
|
||||
"""有任务时 /cancel 清理临时目录并重置状态."""
|
||||
task_dir = Path(server.Config.temp_dir) / "task-cancel"
|
||||
task_dir.mkdir(parents=True, exist_ok=True)
|
||||
(task_dir / "some_file.txt").write_text("temp")
|
||||
|
||||
server.current_task["task_id"] = "task-cancel"
|
||||
server.current_task["start_time"] = time.time()
|
||||
server.current_task["process"] = "inference_thread"
|
||||
|
||||
with server.app.test_client() as c:
|
||||
resp = c.post("/cancel")
|
||||
assert resp.status_code == 200
|
||||
assert "已取消" in resp.get_json()["message"]
|
||||
assert not task_dir.exists()
|
||||
assert server.current_task["task_id"] is None
|
||||
assert server.current_task["process"] is None
|
||||
assert server.current_task["start_time"] == 0.0
|
||||
@@ -1,155 +0,0 @@
|
||||
"""LipsyncService GPU 路径集成测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_db():
|
||||
db = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_mediakit():
|
||||
client = MagicMock()
|
||||
client.submit_lipsync.return_value = {"task_id": "mk-task-1"}
|
||||
return client
|
||||
|
||||
|
||||
def _make_job(video_url="oss://video.mp4", audio_url="oss://audio.wav"):
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
job.user_id = "u1"
|
||||
job.project_id = "p1"
|
||||
job.video_url = video_url
|
||||
job.audio_url = audio_url
|
||||
job.enable_video_loop = True
|
||||
job.script_text = ""
|
||||
job.sentence_timings = None
|
||||
return job
|
||||
|
||||
|
||||
def _make_svc(db, mediakit, use_gpu=False):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
svc = LipsyncService(db=db, client=mediakit)
|
||||
svc.settings.use_gpu_lipsync = use_gpu
|
||||
svc._sign_media_url = lambda u: (u or "") + "?signed"
|
||||
return svc
|
||||
|
||||
|
||||
class TestGpuFallback:
|
||||
def test_switch_off_uses_mediakit(self, fake_db, fake_mediakit):
|
||||
"""开关关闭时直接走 MediaKit,不调用 _submit_to_gpu."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=False)
|
||||
job = _make_job()
|
||||
with patch.object(svc, "_submit_to_gpu") as m_sub:
|
||||
svc._submit_audio_direct(job=job)
|
||||
m_sub.assert_not_called()
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_switch_on_no_worker_falls_back(self, fake_db, fake_mediakit):
|
||||
"""开关打开但 has_available_worker=False → 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = False
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_gpu_svc.create_task.assert_not_called()
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_gpu_success_marks_completed(self, fake_db, fake_mediakit):
|
||||
"""GPU 路径成功:job 直接 completed,不调 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
gpu_done = MagicMock(
|
||||
id="gpu-task-1",
|
||||
status="done",
|
||||
result_url="oss://gpu-results/r.mp4",
|
||||
result_duration=12.5,
|
||||
)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-1")
|
||||
fake_gpu_svc.wait_for_result.return_value = gpu_done
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_gpu_svc.create_task.assert_called_once()
|
||||
fake_mediakit.submit_lipsync.assert_not_called()
|
||||
assert job.status == "completed"
|
||||
assert job.output_duration == 12.5
|
||||
assert "?signed" in job.output_video_url
|
||||
fake_db.commit.assert_called()
|
||||
|
||||
def test_gpu_timeout_falls_back(self, fake_db, fake_mediakit):
|
||||
"""wait_for_result 返回 None(超时)→ 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-t")
|
||||
fake_gpu_svc.wait_for_result.return_value = None
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_gpu_failed_status_falls_back(self, fake_db, fake_mediakit):
|
||||
"""GPU 终态 failed → 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-t")
|
||||
fake_gpu_svc.wait_for_result.return_value = MagicMock(status="failed", error_msg="musetalk crash")
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_gpu_exception_falls_back(self, fake_db, fake_mediakit):
|
||||
"""GPU 路径抛异常 → 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.side_effect = RuntimeError("DB down")
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
|
||||
class TestGpuServiceHelpers:
|
||||
"""GpuLipsyncService.has_available_worker 测试."""
|
||||
|
||||
def test_no_workers(self, fake_db):
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
svc = GpuLipsyncService(db=fake_db)
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = None
|
||||
assert svc.has_available_worker() is False
|
||||
|
||||
def test_fresh_worker_available(self, fake_db):
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
svc = GpuLipsyncService(db=fake_db)
|
||||
svc.settings.gpu_worker_stale_seconds = 300
|
||||
# 模拟SQL filter条件成立 → first() 返回非None
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = MagicMock()
|
||||
assert svc.has_available_worker() is True
|
||||
|
||||
def test_stale_worker_unavailable(self, fake_db):
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
svc = GpuLipsyncService(db=fake_db)
|
||||
# filter条件不成立(stale)→ first() 返回None
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = None
|
||||
assert svc.has_available_worker() is False
|
||||
Reference in New Issue
Block a user