fbf8844f25
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 2s
AI Code Review / AI Code Review (pull_request) Failing after 3s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m52s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m31s
CI/CD Pipeline / Integration Tests (push) Successful in 7m15s
CI/CD Pipeline / Validate - Style (push) Successful in 7m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m57s
CI/CD Pipeline / Unit Tests (push) Successful in 13m56s
CI/CD Pipeline / Validate - Security (push) Successful in 22m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
396 lines
14 KiB
Python
396 lines
14 KiB
Python
"""MuseTalk GPU Worker — 反向轮询模式.
|
||
|
||
部署在有 RTX2060 的本地电脑上(192.168.0.193),
|
||
主动轮询 SaaS API 拉取口型任务、调用本地 MuseTalk 推理、上传结果回 SaaS。
|
||
|
||
环境变量:
|
||
API_BASE_URL SaaS API 基础 URL(不含 /api/v1),如 https://staging-api.xiaoxiajianji.com
|
||
GPU_WORKER_TOKEN 长期 API Token(服务端 GPU_WORKER_TOKEN 需一致)
|
||
WORKER_ID 本机唯一 ID(默认 hostname+网卡MAC 后4位)
|
||
MUSE_TALK_URL 本地 MuseTalk 地址,默认 http://127.0.0.1:7861
|
||
POLL_INTERVAL 轮询间隔秒,默认 5
|
||
HEARTBEAT_INTERVAL 心跳间隔秒,默认 15
|
||
REQUEST_TIMEOUT HTTP 请求超时秒,默认 60
|
||
TASK_MAX_RETRY 单个任务最大重试次数(在 Worker 本地的重试),默认 2
|
||
|
||
用法:
|
||
python gpu_worker.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import platform
|
||
import socket
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import requests
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||
datefmt="%Y-%m-%d %H:%M:%S",
|
||
)
|
||
logger = logging.getLogger("musetalk-worker")
|
||
|
||
# ── 配置 ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _env(name: str, default: str = "") -> str:
|
||
v = os.environ.get(name, default)
|
||
return v.strip() if isinstance(v, str) else default
|
||
|
||
|
||
class Config:
|
||
api_base_url: str = _env("API_BASE_URL", "https://staging-api.xiaoxiajianji.com").rstrip("/")
|
||
gpu_worker_token: str = _env("GPU_WORKER_TOKEN")
|
||
muse_talk_url: str = _env("MUSE_TALK_URL", "http://127.0.0.1:7861").rstrip("/")
|
||
poll_interval: float = float(_env("POLL_INTERVAL", "5"))
|
||
heartbeat_interval: float = float(_env("HEARTBEAT_INTERVAL", "15"))
|
||
request_timeout: float = float(_env("REQUEST_TIMEOUT", "300"))
|
||
task_max_retry: int = int(_env("TASK_MAX_RETRY", "2"))
|
||
worker_id: str = _env("WORKER_ID", "")
|
||
|
||
@classmethod
|
||
def derived_worker_id(cls) -> str:
|
||
if cls.worker_id:
|
||
return cls.worker_id
|
||
# hostname + MAC 后4位 → 稳定唯一 ID
|
||
try:
|
||
mac = uuid.getnode()
|
||
mac_suffix = f"{mac:012x}"[-4:]
|
||
except Exception:
|
||
mac_suffix = "0000"
|
||
host = platform.node() or socket.gethostname() or "rtx2060"
|
||
return f"{host}-{mac_suffix}"
|
||
|
||
|
||
# ── 辅助 ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _api_headers() -> dict[str, str]:
|
||
token = Config.gpu_worker_token
|
||
if not token:
|
||
logger.warning("GPU_WORKER_TOKEN 未配置,开发模式下会被服务端拒绝(生产环境必须配置)")
|
||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||
|
||
|
||
def _check_musetalk_health() -> tuple[bool, dict]:
|
||
"""检查本地 MuseTalk 健康状态,返回 (ok, info)."""
|
||
try:
|
||
r = requests.get(f"{Config.muse_talk_url}/health", timeout=5)
|
||
if r.status_code == 200:
|
||
try:
|
||
return True, r.json()
|
||
except Exception:
|
||
return True, {}
|
||
return False, {"status_code": r.status_code, "body": r.text[:200]}
|
||
except Exception as exc:
|
||
return False, {"error": str(exc)}
|
||
|
||
|
||
def _register() -> bool:
|
||
"""向服务端注册 / 心跳,附带 GPU 信息."""
|
||
ok, info = _check_musetalk_health()
|
||
free_vram = int(info.get("free_vram_mb", 0) or 0) if isinstance(info, dict) else 0
|
||
gpu_name = info.get("gpu_name", "") if isinstance(info, dict) else ""
|
||
if not gpu_name:
|
||
# 尝试在 Windows 上读 nvidia-smi
|
||
gpu_name = _probe_gpu_name()
|
||
payload = {
|
||
"worker_id": Config.derived_worker_id(),
|
||
"hostname": platform.node(),
|
||
"gpu_name": gpu_name,
|
||
"free_vram_mb": free_vram,
|
||
"capabilities": "musetalk",
|
||
}
|
||
try:
|
||
r = requests.post(
|
||
f"{Config.api_base_url}/api/v1/gpu/register",
|
||
json=payload,
|
||
headers=_api_headers(),
|
||
timeout=15,
|
||
)
|
||
if r.status_code == 200:
|
||
return True
|
||
logger.error("注册/心跳失败: HTTP %d body=%s", r.status_code, r.text[:300])
|
||
return False
|
||
except Exception as exc:
|
||
logger.error("注册/心跳异常: %s", exc)
|
||
return False
|
||
|
||
|
||
def _probe_gpu_name() -> str:
|
||
"""尽力探测 GPU 型号(不强制依赖 pynvml)."""
|
||
try:
|
||
import subprocess
|
||
|
||
out = subprocess.check_output(
|
||
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
|
||
stderr=subprocess.DEVNULL,
|
||
timeout=5,
|
||
)
|
||
return out.decode("utf-8", errors="ignore").strip().splitlines()[0].strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _poll_task() -> Optional[dict]:
|
||
"""轮询拉取一条待处理任务;无任务返回 None."""
|
||
try:
|
||
r = requests.get(
|
||
f"{Config.api_base_url}/api/v1/gpu/lipsync/poll",
|
||
params={"worker_id": Config.derived_worker_id()},
|
||
headers=_api_headers(),
|
||
timeout=30,
|
||
)
|
||
if r.status_code == 204:
|
||
return None
|
||
if r.status_code == 200:
|
||
data = r.json()
|
||
return data.get("task")
|
||
logger.error("poll 返回 %d: %s", r.status_code, r.text[:300])
|
||
return None
|
||
except Exception as exc:
|
||
logger.error("poll 异常: %s", exc)
|
||
return None
|
||
|
||
|
||
def _download(url: str, path: Path) -> bool:
|
||
"""下载文件到本地,支持预签名 URL."""
|
||
try:
|
||
with requests.get(url, stream=True, timeout=Config.request_timeout) as r:
|
||
if r.status_code >= 400:
|
||
logger.error("下载失败 HTTP %d: %s", r.status_code, url[:120])
|
||
return False
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(path, "wb") as f:
|
||
for chunk in r.iter_content(chunk_size=1024 * 256):
|
||
if chunk:
|
||
f.write(chunk)
|
||
return path.stat().st_size > 0
|
||
except Exception as exc:
|
||
logger.error("下载异常 %s: %s", url[:120], exc)
|
||
return False
|
||
|
||
|
||
def _call_musetalk(video_path: Path, audio_path: Path, out_path: Path) -> tuple[bool, float, str]:
|
||
"""调用本地 MuseTalk /inference.
|
||
|
||
返回 (success, duration_seconds, error_msg).
|
||
duration 用 ffprobe 读结果视频,失败填 0。
|
||
"""
|
||
try:
|
||
with open(video_path, "rb") as vf, open(audio_path, "rb") as af:
|
||
files = {
|
||
"video": (video_path.name, vf, "video/mp4"),
|
||
"audio": (audio_path.name, af, "application/octet-stream"),
|
||
}
|
||
r = requests.post(
|
||
f"{Config.muse_talk_url}/inference",
|
||
files=files,
|
||
timeout=Config.request_timeout,
|
||
)
|
||
if r.status_code != 200:
|
||
return False, 0.0, f"MuseTalk HTTP {r.status_code}: {r.text[:500]}"
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
out_path.write_bytes(r.content)
|
||
if out_path.stat().st_size < 1024:
|
||
return False, 0.0, f"MuseTalk 返回结果过小 ({out_path.stat().st_size} bytes)"
|
||
duration = _probe_duration(out_path)
|
||
return True, duration, ""
|
||
except requests.exceptions.Timeout:
|
||
return False, 0.0, f"MuseTalk 推理超时(>{Config.request_timeout}s)"
|
||
except Exception as exc:
|
||
return False, 0.0, f"MuseTalk 调用异常: {exc}"
|
||
|
||
|
||
def _probe_duration(path: Path) -> float:
|
||
"""用 ffprobe 读视频时长(若系统装了 ffmpeg);否则返回 0."""
|
||
try:
|
||
import subprocess
|
||
|
||
out = subprocess.check_output(
|
||
[
|
||
"ffprobe", "-v", "error",
|
||
"-show_entries", "format=duration",
|
||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||
str(path),
|
||
],
|
||
stderr=subprocess.DEVNULL,
|
||
timeout=10,
|
||
)
|
||
return float(out.decode().strip() or 0)
|
||
except Exception:
|
||
return 0.0
|
||
|
||
|
||
def _upload_result(upload_url: str, file_path: Path) -> bool:
|
||
"""PUT 上传结果视频到预签名 URL."""
|
||
try:
|
||
with open(file_path, "rb") as f:
|
||
r = requests.put(
|
||
upload_url,
|
||
data=f,
|
||
headers={"Content-Type": "video/mp4"},
|
||
timeout=Config.request_timeout,
|
||
)
|
||
if r.status_code >= 400:
|
||
logger.error("上传结果失败 HTTP %d: %s", r.status_code, r.text[:500])
|
||
return False
|
||
return True
|
||
except Exception as exc:
|
||
logger.error("上传结果异常: %s", exc)
|
||
return False
|
||
|
||
|
||
def _report_result(task_id: str, success: bool, duration: float = 0.0, error_msg: str = "") -> bool:
|
||
"""通知服务端结果。失败时也尝试上报错误(不含视频文件)."""
|
||
try:
|
||
data = {
|
||
"task_id": task_id,
|
||
"worker_id": Config.derived_worker_id(),
|
||
"success": "true" if success else "false",
|
||
"duration_seconds": str(duration),
|
||
"error_msg": error_msg,
|
||
}
|
||
r = requests.post(
|
||
f"{Config.api_base_url}/api/v1/gpu/lipsync/result",
|
||
data=data,
|
||
headers=_api_headers(),
|
||
timeout=30,
|
||
)
|
||
if r.status_code != 200:
|
||
logger.error("上报结果失败 HTTP %d: %s", r.status_code, r.text[:300])
|
||
return False
|
||
return True
|
||
except Exception as exc:
|
||
logger.error("上报结果异常: %s", exc)
|
||
return False
|
||
|
||
|
||
def _handle_task(task: dict) -> None:
|
||
"""处理一条任务(整个串行流程:下载→推理→上传→上报)."""
|
||
task_id = task["task_id"]
|
||
logger.info("开始处理任务 %s", task_id)
|
||
with tempfile.TemporaryDirectory(prefix="musetalk_") as tmpdir:
|
||
tmp = Path(tmpdir)
|
||
video_path = tmp / "input.mp4"
|
||
audio_path = tmp / "input_audio.bin"
|
||
out_path = tmp / "output.mp4"
|
||
|
||
# 1. 下载
|
||
if not _download(task["video_url"], video_path):
|
||
_report_result(task_id, False, 0.0, "下载人物视频失败")
|
||
return
|
||
if not _download(task["audio_url"], audio_path):
|
||
_report_result(task_id, False, 0.0, "下载驱动音频失败")
|
||
return
|
||
|
||
# 2. 推理(本地重试)
|
||
success = False
|
||
duration = 0.0
|
||
err = ""
|
||
for attempt in range(Config.task_max_retry + 1):
|
||
if attempt > 0:
|
||
logger.info("任务 %s 第 %d 次重试...", task_id, attempt + 1)
|
||
time.sleep(2)
|
||
success, duration, err = _call_musetalk(video_path, audio_path, out_path)
|
||
if success:
|
||
break
|
||
if not success:
|
||
logger.error("任务 %s 推理失败: %s", task_id, err)
|
||
_report_result(task_id, False, 0.0, err)
|
||
return
|
||
|
||
# 3. 上报结果(multipart 同时上传文件 → API 代为 PUT 到 OSS,逻辑最稳)
|
||
_report_success_with_file(task_id, duration, out_path)
|
||
|
||
|
||
def _report_success_with_file(task_id: str, duration: float, file_path: Path) -> None:
|
||
"""上报成功并 multipart 附带结果视频."""
|
||
try:
|
||
data = {
|
||
"task_id": task_id,
|
||
"worker_id": Config.derived_worker_id(),
|
||
"success": "true",
|
||
"duration_seconds": str(duration),
|
||
"error_msg": "",
|
||
}
|
||
with open(file_path, "rb") as f:
|
||
files = {"result": (f"{task_id}.mp4", f, "video/mp4")}
|
||
r = requests.post(
|
||
f"{Config.api_base_url}/api/v1/gpu/lipsync/result",
|
||
data=data,
|
||
files=files,
|
||
headers=_api_headers(),
|
||
timeout=Config.request_timeout,
|
||
)
|
||
if r.status_code != 200:
|
||
logger.error("上报成功结果失败 HTTP %d: %s", r.status_code, r.text[:300])
|
||
return
|
||
logger.info("任务 %s 完成,duration=%.1fs", task_id, duration)
|
||
except Exception as exc:
|
||
logger.error("上报成功结果异常: %s", exc)
|
||
|
||
|
||
# ── 主循环 ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def main() -> int:
|
||
logger.info("=" * 60)
|
||
logger.info("MuseTalk GPU Worker 启动")
|
||
logger.info(" worker_id = %s", Config.derived_worker_id())
|
||
logger.info(" api_base = %s", Config.api_base_url)
|
||
logger.info(" muse_talk = %s", Config.muse_talk_url)
|
||
logger.info(" poll = %.1fs / heartbeat = %.1fs", Config.poll_interval, Config.heartbeat_interval)
|
||
logger.info("=" * 60)
|
||
|
||
if not Config.gpu_worker_token:
|
||
logger.warning("GPU_WORKER_TOKEN 未配置(开发模式),生产环境必须设置")
|
||
|
||
# 先检查一次 MuseTalk
|
||
ok, info = _check_musetalk_health()
|
||
if ok:
|
||
logger.info("MuseTalk 健康检查通过: %s", info)
|
||
else:
|
||
logger.warning("MuseTalk 健康检查未通过: %s(继续运行,等待服务可用)", info)
|
||
|
||
# 启动时立即注册
|
||
_register()
|
||
last_heartbeat = time.time()
|
||
|
||
while True:
|
||
try:
|
||
# 心跳
|
||
now = time.time()
|
||
if now - last_heartbeat >= Config.heartbeat_interval:
|
||
if _register():
|
||
last_heartbeat = now
|
||
|
||
# 轮询任务
|
||
task = _poll_task()
|
||
if task is not None:
|
||
_handle_task(task)
|
||
# 处理完立即再 poll(不 sleep),尽可能拉满 GPU
|
||
continue
|
||
|
||
time.sleep(Config.poll_interval)
|
||
except KeyboardInterrupt:
|
||
logger.info("收到中断信号,退出")
|
||
return 0
|
||
except Exception as exc:
|
||
logger.exception("主循环异常: %s", exc)
|
||
time.sleep(Config.poll_interval)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|