Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db621b4fcb | |||
| 60cacdf280 | |||
| 08de0d9946 | |||
| b0b81a5d60 | |||
| d959dd874f | |||
| dcd0c56827 | |||
| 585bab9313 | |||
| 4a449ae496 | |||
| 112f0eb277 | |||
| 2e2d1cd73e | |||
| 32473485d7 |
+16
-4
@@ -198,10 +198,13 @@ DOUBAO_TIMEOUT=30
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
# ==================== 积分/会员系统 (#1895) ====================
|
||||
# 积分扣点总开关:默认 false(对现有用户零影响)。
|
||||
# P2 阶段各业务路由逐个接入 @points_gate 时,用
|
||||
# `if settings.points_enabled: ...`
|
||||
# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开。
|
||||
# 积分系统总开关:默认 false(暂停积分系统)。
|
||||
# - false:生成视频/口型同步/数字人/AI标题/TTS/克隆音色等所有功能对登录
|
||||
# 用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员状态查询接口
|
||||
# 保留可用,但数据不再变动。积分相关的表、代码、接口均保留不删除。
|
||||
# - 恢复积分:设置 ENABLE_CREDIT_SYSTEM=true 即可,无需改代码。
|
||||
ENABLE_CREDIT_SYSTEM=false
|
||||
# 旧开关名(兼容别名):与 ENABLE_CREDIT_SYSTEM 任一为 true 即启用。
|
||||
POINTS_ENABLED=false
|
||||
|
||||
# ==================== 抖音解析多源轮询 (#1963) ====================
|
||||
@@ -220,4 +223,13 @@ 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
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.points import (
|
||||
DailyUsageResponse,
|
||||
@@ -44,6 +45,12 @@ from packages.domain.points_service import PointsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _credits_enabled() -> bool:
|
||||
"""积分系统总开关(ENABLE_CREDIT_SYSTEM),关闭时全部功能免费放行。"""
|
||||
return bool(getattr(settings, "points_enabled", False))
|
||||
|
||||
|
||||
# ── 两个 router ──
|
||||
points_router = APIRouter()
|
||||
usage_router = APIRouter()
|
||||
@@ -172,6 +179,19 @@ def check_points(
|
||||
"valid_scenes": sorted(POINTS_SCENES.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
# 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分
|
||||
if not _credits_enabled():
|
||||
svc = _get_service()
|
||||
account = svc.get_or_create_account(current_user.user.id, db)
|
||||
return PointsCheckResponse(
|
||||
allowed=True,
|
||||
required_points=0,
|
||||
current_balance=account["balance"],
|
||||
remaining_after=account["balance"],
|
||||
is_free_quota=False,
|
||||
)
|
||||
|
||||
is_mem = _is_member(current_user)
|
||||
mt = _member_type(current_user)
|
||||
|
||||
@@ -209,8 +229,19 @@ def deduct_points(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""积分扣减(内部服务调用)。"""
|
||||
"""积分扣减(内部服务调用)。
|
||||
|
||||
积分系统暂停(ENABLE_CREDIT_SYSTEM=false)时为 no-op:不扣分、余额不变,
|
||||
直接返回成功,保证内部调用方拿到 success=True 继续业务流程。
|
||||
"""
|
||||
svc = _get_service()
|
||||
if not _credits_enabled():
|
||||
account = svc.get_or_create_account(current_user.user.id, db)
|
||||
return SimpleMessageResponse(
|
||||
success=True,
|
||||
message="积分系统已暂停,未扣减积分",
|
||||
data={"transaction_id": "", "balance": account["balance"]},
|
||||
)
|
||||
result = svc.deduct_points(
|
||||
user_id=current_user.user.id,
|
||||
amount=body.amount,
|
||||
@@ -243,11 +274,7 @@ def refund_points(
|
||||
"""积分退还(内部服务调用)。"""
|
||||
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
|
||||
|
||||
txn = (
|
||||
db.query(PointsTransactionModel)
|
||||
.filter(PointsTransactionModel.id == body.transaction_id)
|
||||
.first()
|
||||
)
|
||||
txn = db.query(PointsTransactionModel).filter(PointsTransactionModel.id == body.transaction_id).first()
|
||||
if txn is None:
|
||||
raise HTTPException(status_code=404, detail="交易记录不存在")
|
||||
if txn.user_id != current_user.user.id:
|
||||
|
||||
@@ -325,3 +325,58 @@ 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)
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.services.mediakit_client import (
|
||||
MediaKitError,
|
||||
get_mediakit_client,
|
||||
)
|
||||
from app.tasks.lipsync_gpu import lipsync_gpu_process_async
|
||||
|
||||
# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径)
|
||||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||||
@@ -36,6 +37,7 @@ 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,
|
||||
@@ -63,6 +65,7 @@ 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 预签名配置)."""
|
||||
@@ -215,7 +218,57 @@ class LipsyncService:
|
||||
if timings:
|
||||
job.sentence_timings = timings
|
||||
|
||||
# 4. 签名 URL 并提交 MediaKit
|
||||
# 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_create(job=job, gpu_svc=gpu_svc)
|
||||
if gpu_task is not None:
|
||||
# GPU 任务已创建,设为 processing 并异步等待结果
|
||||
job.mediakit_task_id = f"gpu:{gpu_task.id}"
|
||||
job.status = "processing"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
# 派发 Celery 异步任务处理 GPU 等待+结果回写
|
||||
try:
|
||||
lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id))
|
||||
logger.info(
|
||||
"[lipsync] GPU 任务已异步派发: job_id=%s gpu_task=%s",
|
||||
job.id,
|
||||
gpu_task.id,
|
||||
)
|
||||
except Exception as celery_exc:
|
||||
logger.warning(
|
||||
"[lipsync] Celery 派发失败,降级同步等待: job_id=%s err=%s",
|
||||
job.id,
|
||||
celery_exc,
|
||||
)
|
||||
self._submit_to_gpu_wait(job=job, gpu_svc=gpu_svc, gpu_task=gpu_task)
|
||||
return
|
||||
# create 失败 → 回退 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(兜底路径)
|
||||
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
|
||||
@@ -244,6 +297,120 @@ class LipsyncService:
|
||||
self.db.commit()
|
||||
raise
|
||||
|
||||
# ── GPU MuseTalk 路径 ────────────────────────────────────────────────
|
||||
|
||||
def _is_own_oss_url(self, url: str, storage) -> bool:
|
||||
"""判断 URL / 存储 key 是否属于自家 OSS。
|
||||
|
||||
- 裸存储 key(无 scheme):自家对象
|
||||
- host 与 storage.public_url host 一致:自家对象
|
||||
- 其余 http(s) 公网链接(如 dashscope-result 临时地址):外部对象
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme:
|
||||
return True # 裸存储 key
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
own_host = urlparse(public_base).netloc.lower() if public_base else ""
|
||||
return bool(own_host) and parsed.netloc.lower() == own_host
|
||||
|
||||
def _persist_external_audio_for_gpu(self, *, job, storage) -> Optional[str]:
|
||||
"""GPU 任务创建前,把外部域名的预合成 TTS 音频转存到自家 OSS。
|
||||
|
||||
Worker 部署在用户家庭网络,dashscope-result 等第三方临时 OSS 地址
|
||||
可能无法访问;转存后 gpu_svc 在 poll 时会签自家预签名 URL 给 Worker。
|
||||
已是自家 OSS 对象(含裸 key)直接返回 None(无需转存);
|
||||
转存失败返回 None,调用方回退使用原始 URL(最坏情况是 Worker 拉取失败,
|
||||
服务端重试耗尽后回退 MediaKit,不阻断业务)。
|
||||
"""
|
||||
if self._is_own_oss_url(job.audio_url, storage):
|
||||
return None
|
||||
try:
|
||||
audio_data = safe_download_bytes(
|
||||
job.audio_url,
|
||||
purpose="lipsync_gpu_tts_audio",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
storage_key = f"lipsync-tts/{job.user_id}/{job.id}.mp3"
|
||||
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
|
||||
logger.info(
|
||||
"[lipsync] GPU 任务外部音频已转存自家 OSS: job_id=%s key=%s",
|
||||
job.id,
|
||||
storage_key,
|
||||
)
|
||||
return permanent_url
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[lipsync] GPU 任务外部音频转存 OSS 失败,回退原始 URL: job_id=%s err=%s",
|
||||
job.id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
def _submit_to_gpu_create(self, *, job, gpu_svc) -> Optional[object]:
|
||||
"""创建 GPU 任务并立即返回(异步模式)。
|
||||
|
||||
成功返回 gpu_task 对象;创建失败返回 None。
|
||||
不再同步等待结果,结果由 Celery 异步任务 lipsync_gpu_process_async 回写。
|
||||
"""
|
||||
storage = get_shared_storage_service()
|
||||
persisted_audio_url = self._persist_external_audio_for_gpu(job=job, storage=storage)
|
||||
audio_url_for_task = persisted_audio_url or job.audio_url
|
||||
gpu_task = gpu_svc.create_task(
|
||||
video_url=job.video_url,
|
||||
audio_url=audio_url_for_task,
|
||||
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,
|
||||
)
|
||||
return gpu_task
|
||||
|
||||
def _submit_to_gpu_wait(self, *, job, gpu_svc, gpu_task) -> None:
|
||||
"""同步等待 GPU 结果(Celery 派发失败时的降级路径)。"""
|
||||
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
|
||||
if final_task.status != "done":
|
||||
logger.warning(
|
||||
"[lipsync] GPU 同步等待失败: gpu_task=%s status=%s",
|
||||
gpu_task.id,
|
||||
final_task.status,
|
||||
)
|
||||
return
|
||||
try:
|
||||
storage = get_shared_storage_service()
|
||||
signed_result_url = storage.get_download_url(
|
||||
final_task.result_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS
|
||||
)
|
||||
if signed_result_url:
|
||||
final_task.result_url = signed_result_url
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[lipsync] GPU 结果签名失败: gpu_task=%s err=%s",
|
||||
gpu_task.id,
|
||||
exc,
|
||||
)
|
||||
job.mediakit_task_id = ""
|
||||
job.status = STATUS_COMPLETED
|
||||
job.output_video_url = final_task.result_url
|
||||
job.output_duration = final_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 同步等待完成: job_id=%s duration=%.2f",
|
||||
job.id,
|
||||
job.output_duration,
|
||||
)
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
@@ -478,6 +645,29 @@ class LipsyncService:
|
||||
if job.status in (STATUS_COMPLETED, "failed"):
|
||||
return job
|
||||
|
||||
# GPU 异步路径:mediakit_task_id 以 "gpu:" 开头,由 Celery 任务异步更新
|
||||
# 不做 MediaKit 轮询,只检查是否卡住太久(>30 分钟)则标失败
|
||||
if job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
|
||||
if job.status in ("processing", "gpu_processing"):
|
||||
_now = datetime.now(UTC)
|
||||
_upd = job.updated_at
|
||||
if _upd is not None and _upd.tzinfo is None:
|
||||
_upd = _upd.replace(tzinfo=UTC)
|
||||
stale_minutes = 30
|
||||
if _upd and (_now - _upd).total_seconds() > stale_minutes * 60:
|
||||
logger.warning(
|
||||
"GPU 异步任务超时(>%d 分钟),标记失败: job_id=%s",
|
||||
stale_minutes,
|
||||
job_id,
|
||||
)
|
||||
job.status = "failed"
|
||||
job.error_message = f"GPU 处理超时(>{stale_minutes} 分钟)"
|
||||
job.error_code = "GpuTimeout"
|
||||
job.completed_at = _now
|
||||
job.updated_at = _now
|
||||
self.db.commit()
|
||||
return job
|
||||
|
||||
# 未提交的任务不轮询
|
||||
if not job.mediakit_task_id:
|
||||
return job
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""GPU MuseTalk 异步推理任务 — 将 GPU 推理等待从 HTTP 请求移至 Celery 后台执行.
|
||||
|
||||
优化目标:将 POST /lipsync/jobs 的 API 响应时间从 >200s 降到 <1s。
|
||||
任务流程:
|
||||
1. 加载 LipsyncJob,获取 gpu_task_id
|
||||
2. 调用 GpuLipsyncService.wait_for_result 轮询等待 GPU 完成
|
||||
3. 签名结果 URL(7 天),更新 job 为 completed
|
||||
4. 失败/超时时:尝试 MediaKit 兜底,若仍失败则标记 job 为 failed
|
||||
|
||||
使用 @shared_task 确保被 Worker 侧 celery_app 正确注册。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 与 LipsyncService 保持一致
|
||||
_MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
|
||||
|
||||
|
||||
def _get_db_session() -> Session:
|
||||
"""获取 DB session(兼容 API 和 Worker 两种运行时)."""
|
||||
try:
|
||||
from worker_app.db import SessionLocal # type: ignore
|
||||
except ImportError:
|
||||
from app.db import SessionLocal # type: ignore
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
def _sign_media_url(url: str) -> str:
|
||||
"""对自家 OSS URL 签 7 天预签名。"""
|
||||
if not url:
|
||||
return url
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
if not isinstance(public_base, str) or not public_base:
|
||||
return url
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
host = urlparse(url).netloc.lower()
|
||||
if not own_host or host != own_host:
|
||||
return url
|
||||
return storage.get_download_url(url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="lipsync_gpu_process_async",
|
||||
bind=True,
|
||||
max_retries=0,
|
||||
acks_late=True,
|
||||
)
|
||||
def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str) -> None:
|
||||
"""异步处理 GPU MuseTalk 推理。
|
||||
|
||||
Args:
|
||||
job_id: LipsyncJob 的 ID
|
||||
user_id: 用户 ID
|
||||
gpu_task_id: GpuLipsyncTask 的 ID
|
||||
"""
|
||||
db: Session = _get_db_session()
|
||||
try:
|
||||
job = db.query(LipsyncJobModel).filter_by(id=job_id, user_id=user_id).first()
|
||||
if job is None:
|
||||
logger.error("[lipsync_gpu_async] job 不存在: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
# 确保状态为 processing
|
||||
if job.status not in ("processing", "gpu_processing"):
|
||||
logger.warning(
|
||||
"[lipsync_gpu_async] job 状态异常,跳过: job_id=%s status=%s",
|
||||
job_id,
|
||||
job.status,
|
||||
)
|
||||
return
|
||||
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
gpu_svc = GpuLipsyncService(db)
|
||||
final_task = gpu_svc.wait_for_result(gpu_task_id)
|
||||
|
||||
if final_task is None:
|
||||
logger.warning(
|
||||
"[lipsync_gpu_async] GPU 超时,回退 MediaKit: job_id=%s gpu_task=%s",
|
||||
job_id,
|
||||
gpu_task_id,
|
||||
)
|
||||
_fallback_to_mediakit(db, job)
|
||||
return
|
||||
|
||||
if final_task.status != "done":
|
||||
logger.warning(
|
||||
"[lipsync_gpu_async] GPU 失败,回退 MediaKit: job_id=%s gpu_task=%s status=%s",
|
||||
job_id,
|
||||
gpu_task_id,
|
||||
final_task.status,
|
||||
)
|
||||
_fallback_to_mediakit(db, job)
|
||||
return
|
||||
|
||||
# 签名结果 URL
|
||||
result_url = final_task.result_url or ""
|
||||
try:
|
||||
storage = get_shared_storage_service()
|
||||
signed = storage.get_download_url(result_url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
|
||||
if signed:
|
||||
result_url = signed
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[lipsync_gpu_async] 签名失败,用原 URL: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
job.status = "completed"
|
||||
job.output_video_url = result_url
|
||||
job.output_duration = final_task.result_duration or 0.0
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[lipsync_gpu_async] GPU 完成: job_id=%s duration=%.2f",
|
||||
job_id,
|
||||
job.output_duration,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("[lipsync_gpu_async] 异常: job_id=%s err=%s", job_id, exc)
|
||||
try:
|
||||
job = db.query(LipsyncJobModel).filter_by(id=job_id).first()
|
||||
if job:
|
||||
job.status = "failed"
|
||||
job.error_message = f"GPU 异步处理异常: {exc}"
|
||||
job.error_code = "GpuAsyncError"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _fallback_to_mediakit(db: Session, job: LipsyncJobModel) -> None:
|
||||
"""GPU 失败时回退到 MediaKit 云端渲染。"""
|
||||
try:
|
||||
from app.services.mediakit_client import MediaKitError, get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
video_url = _sign_media_url(job.video_url)
|
||||
audio_url = _sign_media_url(job.audio_url)
|
||||
|
||||
result = client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=job.enable_video_loop,
|
||||
client_token=job.id,
|
||||
)
|
||||
job.mediakit_task_id = result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[lipsync_gpu_async] 已回退 MediaKit: job_id=%s task_id=%s",
|
||||
job.id,
|
||||
result["task_id"],
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.error_code = exc.code
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.error("[lipsync_gpu_async] MediaKit 也失败: job_id=%s err=%s", job.id, exc)
|
||||
except Exception as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = f"GPU+MediaKit 均失败: {exc}"
|
||||
job.error_code = "FallbackFailed"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.error("[lipsync_gpu_async] 兜底异常: job_id=%s err=%s", job.id, exc)
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
import "./PointsBadge.css"
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
@@ -32,9 +33,13 @@ const PointsBadge: React.FC = () => {
|
||||
const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!ENABLE_CREDIT_SYSTEM) return
|
||||
if (!balance) init()
|
||||
}, [balance, init])
|
||||
|
||||
// 功能开关:积分系统关闭时直接隐藏徽章
|
||||
if (!ENABLE_CREDIT_SYSTEM) return null
|
||||
|
||||
// 余额:优先用 membership.points_balance(冗余字段),降级 balance.balance
|
||||
const bal = membership?.points_balance ?? balance?.balance ?? 0
|
||||
const lowBalance = bal > 0 && bal < 10
|
||||
|
||||
@@ -15,6 +15,7 @@ import React, { useMemo } from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import { WarningOutlined } from "@ant-design/icons"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
import type { PointsSource } from "@/api/points/types"
|
||||
import "./PointsCost.css"
|
||||
|
||||
@@ -53,7 +54,7 @@ const PointsCost: React.FC<Props> = ({
|
||||
compact = false,
|
||||
showRechargeHint = true,
|
||||
className = "",
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { balance, dailyUsage, rules, membership } = usePointsStore()
|
||||
const qty = quantity ?? units ?? 1
|
||||
|
||||
@@ -118,6 +119,9 @@ const PointsCost: React.FC<Props> = ({
|
||||
}
|
||||
}, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes])
|
||||
|
||||
// 积分系统关闭时不展示消耗提示(组件保留,hooks 必须在 return 前调用)
|
||||
if (!ENABLE_CREDIT_SYSTEM) return null
|
||||
|
||||
if (!rule || !balance) {
|
||||
return <span className={`xx-points-cost ${className}`} />
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useLogout } from "@/hooks/useAuth"
|
||||
import type { MenuProps } from "antd"
|
||||
import { NAV_ITEMS } from "@/config/navigation"
|
||||
import PointsBadge from "@/components/common/PointsBadge"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import "./Header.css"
|
||||
|
||||
@@ -57,30 +58,36 @@ const Header: React.FC = () => {
|
||||
label: "订阅管理",
|
||||
onClick: () => navigate("/app/subscription"),
|
||||
},
|
||||
// v2: 我的积分入口
|
||||
{
|
||||
key: "points-center",
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: (
|
||||
<Space>
|
||||
我的积分
|
||||
{balance && <span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>}
|
||||
</Space>
|
||||
),
|
||||
onClick: () => navigate("/app/points"),
|
||||
},
|
||||
{
|
||||
key: "points-history",
|
||||
icon: <HistoryOutlined />,
|
||||
label: "积分明细",
|
||||
onClick: () => navigate("/app/points/transactions"),
|
||||
},
|
||||
{
|
||||
key: "recharge",
|
||||
icon: <WalletOutlined />,
|
||||
label: "充值积分",
|
||||
onClick: () => navigate("/app/points/recharge"),
|
||||
},
|
||||
// 积分系统开关关闭时隐藏积分相关菜单项(代码保留不删除)
|
||||
...(ENABLE_CREDIT_SYSTEM
|
||||
? [
|
||||
{
|
||||
key: "points-center",
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: (
|
||||
<Space>
|
||||
我的积分
|
||||
{balance && (
|
||||
<span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
onClick: () => navigate("/app/points"),
|
||||
},
|
||||
{
|
||||
key: "points-history",
|
||||
icon: <HistoryOutlined />,
|
||||
label: "积分明细",
|
||||
onClick: () => navigate("/app/points/transactions"),
|
||||
},
|
||||
{
|
||||
key: "recharge",
|
||||
icon: <WalletOutlined />,
|
||||
label: "充值积分",
|
||||
onClick: () => navigate("/app/points/recharge"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "logout",
|
||||
@@ -130,7 +137,13 @@ const Header: React.FC = () => {
|
||||
|
||||
{/* v2: 升级会员入口(仅免费用户显示) */}
|
||||
{!isMember && (
|
||||
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
|
||||
<Tooltip
|
||||
title={
|
||||
ENABLE_CREDIT_SYSTEM
|
||||
? "升级会员解锁无限混剪、批量导出,积分 8 折起"
|
||||
: "升级会员解锁无限混剪、批量导出"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 功能开关配置
|
||||
* 集中管理前端特性的启用/隐藏,便于灰度与回滚。
|
||||
* 注意:仅控制 UI 展示与前端校验,后端扣减逻辑由后端对应开关控制。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 积分系统 UI 开关(默认 false = 隐藏)
|
||||
* - false:隐藏所有积分相关入口/余额/消耗提示/不足弹窗/充值入口;会员标识保留;
|
||||
* 功能流程不做积分预校验,直接走生成。
|
||||
* - true:展示完整积分系统 UI。
|
||||
*/
|
||||
export const ENABLE_CREDIT_SYSTEM = false
|
||||
@@ -3,6 +3,7 @@
|
||||
* Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复
|
||||
*/
|
||||
import React from "react"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "./features"
|
||||
import {
|
||||
DashboardOutlined,
|
||||
FileOutlined,
|
||||
@@ -105,12 +106,17 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
|
||||
...(ENABLE_CREDIT_SYSTEM
|
||||
? [
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
@@ -200,12 +206,17 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
|
||||
...(ENABLE_CREDIT_SYSTEM
|
||||
? [
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -44,7 +44,8 @@ export const createLipsyncJob = async (data: {
|
||||
enable_video_loop?: boolean
|
||||
project_id?: string
|
||||
}): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
|
||||
// GPU 口型同步推理约 20s,留足余量到 120s 防止 10s 默认超时
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data, { timeout: 120_000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { hasEnoughPoints } from "./hooks/pointsCost"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
import "./generate.css"
|
||||
import "./generate-points.css"
|
||||
|
||||
@@ -437,19 +438,22 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
// 积分预检查
|
||||
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const check = hasEnoughPoints(
|
||||
balance ?? null,
|
||||
units,
|
||||
dailyUsage ?? null,
|
||||
[],
|
||||
"free",
|
||||
rules?.free_user_multiplier ?? 1.15,
|
||||
)
|
||||
if (!check.sufficient) {
|
||||
message.error(check.reason ?? "积分不足,请充值")
|
||||
return
|
||||
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
|
||||
let check: ReturnType<typeof hasEnoughPoints> = { sufficient: true, cost: 0 }
|
||||
if (ENABLE_CREDIT_SYSTEM) {
|
||||
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
check = hasEnoughPoints(
|
||||
balance ?? null,
|
||||
units,
|
||||
dailyUsage ?? null,
|
||||
[],
|
||||
"free",
|
||||
rules?.free_user_multiplier ?? 1.15,
|
||||
)
|
||||
if (!check.sufficient) {
|
||||
message.error(check.reason ?? "积分不足,请充值")
|
||||
return
|
||||
}
|
||||
}
|
||||
if (isBatch) {
|
||||
if (selectedVariantIds.length === 0) {
|
||||
@@ -520,19 +524,18 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
|
||||
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const pointsEstimate = useMemo(
|
||||
() =>
|
||||
hasEnoughPoints(
|
||||
balance ?? null,
|
||||
unitsForCost,
|
||||
dailyUsage ?? null,
|
||||
[],
|
||||
"free",
|
||||
rules?.free_user_multiplier ?? 1.15,
|
||||
),
|
||||
[unitsForCost, balance, dailyUsage, rules],
|
||||
)
|
||||
const insufficientPoints = !pointsEstimate.sufficient
|
||||
const pointsEstimate = useMemo(() => {
|
||||
if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 }
|
||||
return hasEnoughPoints(
|
||||
balance ?? null,
|
||||
unitsForCost,
|
||||
dailyUsage ?? null,
|
||||
[],
|
||||
"free",
|
||||
rules?.free_user_multiplier ?? 1.15,
|
||||
)
|
||||
}, [unitsForCost, balance, dailyUsage, rules])
|
||||
const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
|
||||
@@ -39,6 +39,7 @@ import { getDiscountPriceCents } from "@/api/points/types"
|
||||
import type { SubscriptionPlan } from "@/api/subscription/types"
|
||||
import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types"
|
||||
import "./Plans.css"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
@@ -249,17 +250,23 @@ const Plans: React.FC = () => {
|
||||
return (
|
||||
<div className="xx-plans-page">
|
||||
<PageHead
|
||||
title="会员与积分"
|
||||
description="开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
|
||||
title={ENABLE_CREDIT_SYSTEM ? "会员与积分" : "会员订阅"}
|
||||
description={
|
||||
ENABLE_CREDIT_SYSTEM
|
||||
? "开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
|
||||
: "开通会员解锁全部功能"
|
||||
}
|
||||
actions={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => navigate("/app/points/transactions")}
|
||||
>
|
||||
积分明细
|
||||
</Button>
|
||||
</Space>
|
||||
ENABLE_CREDIT_SYSTEM ? (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => navigate("/app/points/transactions")}
|
||||
>
|
||||
积分明细
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -296,13 +303,15 @@ const Plans: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">可用积分</Text>
|
||||
<div className="xx-current-balance">
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
|
||||
<span className="xx-current-balance-val">{bal}</span>
|
||||
{ENABLE_CREDIT_SYSTEM && (
|
||||
<div>
|
||||
<Text type="secondary">可用积分</Text>
|
||||
<div className="xx-current-balance">
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
|
||||
<span className="xx-current-balance-val">{bal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isMember && freeLimit > 0 && (
|
||||
<div>
|
||||
<Text type="secondary">今日免费混剪</Text>
|
||||
@@ -319,18 +328,20 @@ const Plans: React.FC = () => {
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => {
|
||||
const el = document.getElementById("points-packages")
|
||||
el?.scrollIntoView({ behavior: "smooth" })
|
||||
}}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
</Col>
|
||||
{ENABLE_CREDIT_SYSTEM && (
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => {
|
||||
const el = document.getElementById("points-packages")
|
||||
el?.scrollIntoView({ behavior: "smooth" })
|
||||
}}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
@@ -461,69 +472,71 @@ const Plans: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 积分充值 */}
|
||||
<div id="points-packages">
|
||||
<Title level={4} style={{ marginTop: 40 }}>
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
|
||||
积分充值
|
||||
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
|
||||
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
|
||||
(永久有效)
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Title>
|
||||
{/* 积分充值(积分系统关闭时隐藏,代码保留不删除) */}
|
||||
{ENABLE_CREDIT_SYSTEM && (
|
||||
<div id="points-packages">
|
||||
<Title level={4} style={{ marginTop: 40 }}>
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
|
||||
积分充值
|
||||
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
|
||||
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
|
||||
(永久有效)
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Title>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{packages.map((pkg) => {
|
||||
const priceCents = getDiscountPriceCents(pkg, userDiscount)
|
||||
const originalCents = pkg.price_cents
|
||||
const discount =
|
||||
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
|
||||
const unit = priceCents / 100 / pkg.points
|
||||
const isHot = pkg.unit_price < 0.1
|
||||
return (
|
||||
<Col xs={24} sm={8} key={pkg.code}>
|
||||
<Card
|
||||
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
|
||||
hoverable
|
||||
>
|
||||
{isHot && <div className="xx-pkg-badge">热门</div>}
|
||||
{discount > 0 && (
|
||||
<Tag color="gold" className="xx-pkg-discount">
|
||||
{Math.round((priceCents / originalCents) * 10) / 1}折
|
||||
</Tag>
|
||||
)}
|
||||
<div className="xx-pkg-name">{pkg.name}</div>
|
||||
<div className="xx-pkg-points">
|
||||
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
|
||||
</div>
|
||||
<div className="xx-pkg-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">
|
||||
{(priceCents / 100)
|
||||
.toFixed(priceCents % 100 === 0 ? 0 : 1)
|
||||
.replace(/\.0$/, "")}
|
||||
</span>
|
||||
{discount > 0 && (
|
||||
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分</div>
|
||||
<Button
|
||||
block
|
||||
type={isHot ? "primary" : "default"}
|
||||
loading={buying === pkg.code}
|
||||
onClick={() => handleBuyPoints(pkg)}
|
||||
style={{ marginTop: 12 }}
|
||||
<Row gutter={[16, 16]}>
|
||||
{packages.map((pkg) => {
|
||||
const priceCents = getDiscountPriceCents(pkg, userDiscount)
|
||||
const originalCents = pkg.price_cents
|
||||
const discount =
|
||||
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
|
||||
const unit = priceCents / 100 / pkg.points
|
||||
const isHot = pkg.unit_price < 0.1
|
||||
return (
|
||||
<Col xs={24} sm={8} key={pkg.code}>
|
||||
<Card
|
||||
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
|
||||
hoverable
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
{isHot && <div className="xx-pkg-badge">热门</div>}
|
||||
{discount > 0 && (
|
||||
<Tag color="gold" className="xx-pkg-discount">
|
||||
{Math.round((priceCents / originalCents) * 10) / 1}折
|
||||
</Tag>
|
||||
)}
|
||||
<div className="xx-pkg-name">{pkg.name}</div>
|
||||
<div className="xx-pkg-points">
|
||||
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
|
||||
</div>
|
||||
<div className="xx-pkg-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">
|
||||
{(priceCents / 100)
|
||||
.toFixed(priceCents % 100 === 0 ? 0 : 1)
|
||||
.replace(/\.0$/, "")}
|
||||
</span>
|
||||
{discount > 0 && (
|
||||
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分</div>
|
||||
<Button
|
||||
block
|
||||
type={isHot ? "primary" : "default"}
|
||||
loading={buying === pkg.code}
|
||||
onClick={() => handleBuyPoints(pkg)}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - subscription: GET /subscription/current(plan_id + billing_cycle)
|
||||
*/
|
||||
import { create } from "zustand"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
import { getPointsBalance, getPointsRules, getDailyUsage, getMembership } from "@/api/points"
|
||||
import { getCurrentSubscription } from "@/api/subscription"
|
||||
import type {
|
||||
@@ -49,14 +50,29 @@ export const usePointsStore = create<PointsState>((set, get) => ({
|
||||
|
||||
init: async () => {
|
||||
// 已加载过不重复拉取
|
||||
if (get().balance && get().rules && get().subscription) return
|
||||
// 积分系统关闭时:只要 subscription/membership 已有值就跳过;开启时需 balance+rules+subscription 齐了才跳过
|
||||
if (ENABLE_CREDIT_SYSTEM) {
|
||||
if (get().balance && get().rules && get().subscription) return
|
||||
} else {
|
||||
if (get().subscription && get().membership) return
|
||||
}
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
// 积分系统关闭时不拉取余额/规则/每日额度,但仍拉会员/订阅用于 VIP 标识展示
|
||||
const balancePromise = ENABLE_CREDIT_SYSTEM
|
||||
? getPointsBalance().catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
const rulesPromise = ENABLE_CREDIT_SYSTEM
|
||||
? getPointsRules().catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
const dailyUsagePromise = ENABLE_CREDIT_SYSTEM
|
||||
? getDailyUsage().catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
const [balance, rules, subscription, dailyUsage, membership] = await Promise.all([
|
||||
getPointsBalance().catch(() => null),
|
||||
getPointsRules().catch(() => null),
|
||||
balancePromise,
|
||||
rulesPromise,
|
||||
getCurrentSubscription().catch(() => null),
|
||||
getDailyUsage().catch(() => null),
|
||||
dailyUsagePromise,
|
||||
getMembership().catch(() => null),
|
||||
])
|
||||
set({
|
||||
|
||||
@@ -259,3 +259,7 @@ 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,3 +276,7 @@ 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
|
||||
|
||||
+315
-62
@@ -1,87 +1,172 @@
|
||||
# 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_$$` |
|
||||
| `MUSE_VIDEO_ENCODER` | 兜底循环视频时的编码器:`auto`(优先 h264_nvenc,失败回退 libx264)/`h264_nvenc`/`libx264` | `auto` |
|
||||
|
||||
SaaS 后端部署完成后需配置:
|
||||
### 2.2 更新部署(v2 性能修复,必做)
|
||||
|
||||
1. 服务端环境变量 `GPU_WORKER_TOKEN` 设为一个随机强 Token(和 Worker `.env` 中一致)
|
||||
2. 数据库已跑迁移 `081_add_gpu_lipsync_tasks`(自动随 API 启动的 alembic upgrade head 完成)
|
||||
3. OSS bucket 中 `gpu-lipsync/results/` 路径可写(默认 bucket 已配)
|
||||
> ⚠️ 2026-09-20 v2 架构:修复 16 倍性能回归。旧版在推理前 loop 视频导致 MuseTalk 处理帧数翻倍、RTX2060 推理 >200s、nginx 504。**必须重新拉取并重启**:
|
||||
|
||||
## 五、验证联调
|
||||
```bash
|
||||
# 在 RTX2060 上备份旧文件并拉取新版本
|
||||
cp ~/projects/MuseTalk/musetalk_server.py ~/projects/MuseTalk/musetalk_server.py.bak
|
||||
wget -O ~/projects/MuseTalk/musetalk_server.py \
|
||||
"https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker/musetalk_server.py"
|
||||
|
||||
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` 非空
|
||||
# 重启服务
|
||||
sudo systemctl restart musetalk-server
|
||||
sudo systemctl status musetalk-server
|
||||
curl http://127.0.0.1:7861/health
|
||||
```
|
||||
|
||||
## 六、故障排查
|
||||
v2 架构核心变化:
|
||||
|
||||
- **MuseTalk 直传全量音频**:不再在推理前用 ffmpeg 循环视频。MuseTalk 原生支持长音频输入,内部自动循环视频帧。推理时间不变(~14s/5s 视频)
|
||||
- **ffmpeg 只做快速封装**:`-c:v copy -c:a aac -shortest`,秒级完成,不重编码
|
||||
- **循环仅兜底**:仅当 MuseTalk 输出画面短于音频时(极端情况),才 `-stream_loop` + NVENC 兜底
|
||||
- **删除 `MUSE_ENABLE_VIDEO_LOOP`**:不再需要此开关,MuseTalk 原生处理
|
||||
|
||||
### 2.3 启动服务
|
||||
|
||||
```bash
|
||||
# 前台运行(调试用)
|
||||
python musetalk_server.py
|
||||
|
||||
# 后台运行(生产用 systemd)
|
||||
sudo systemctl start musetalk-server
|
||||
sudo systemctl enable musetalk-server
|
||||
```
|
||||
|
||||
### 2.4 验证健康检查
|
||||
|
||||
```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 +177,177 @@ 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 显存信息和当前任务状态
|
||||
|
||||
2026-09-20 追加修复(音轨正确性,上线阻断级):
|
||||
|
||||
9. **音轨未替换(严重)**:旧最终封装让 ffmpeg 默认选流,结果保留了源视频自带音轨(与画面相关系数 0.9998,与 TTS 无关)。改为 `_mux_video_with_audio()` 统一封装,强制 `-map 0:v:0 -map 1:a:0`,画面取 MuseTalk 无声产物、音轨只取驱动音频
|
||||
10. **音视频时长不对齐**:TTS 长于原视频时 `-shortest` 会截短语音。改为探测双方时长,音频更长时 `-stream_loop -1` 循环画面 + `h264_nvenc` 硬件重编码(`MUSE_VIDEO_ENCODER=auto`,失败回退 libx264)+ `-t <音频时长>`;不循环时 `-c:v copy` 秒封装
|
||||
- 开关 `MUSE_ENABLE_VIDEO_LOOP=0` 可关闭循环;请求也支持 form 参数 `enable_video_loop` 单任务覆盖
|
||||
|
||||
2026-09-20 v2 架构重构(性能回归修复,上线阻断级):
|
||||
|
||||
11. **16 倍性能回归**:#9/#10 的实现虽然音轨正确,但在某些集成场景下(推理前 loop 视频再喂 MuseTalk)导致推理帧数 ×2.2 + 叠加 ffmpeg 软编码预处理,5s 视频 +11s 音频推理 >200s,nginx 60s 超时 504
|
||||
- **正确架构**:MuseTalk 原生支持长音频输入,内部自动循环视频帧。把【原视频】+【全量音频】直传 MuseTalk,输出时长=音频时长
|
||||
- **ffmpeg 后置快速封装**:`-c:v copy -c:a aac -shortest` 秒级完成,不重编码
|
||||
- **循环仅兜底**:仅当 MuseTalk 输出画面短于音频时(极端情况),才 `-stream_loop` + NVENC 兜底补齐
|
||||
- **业务侧异步化**:POST /lipsync/jobs 创建 GPU 任务后立即返回 `job.status="processing"`,Celery 异步等待结果回写。前端 GET /jobs/{id} 轮询。避免同步阻塞 HTTP 请求 >200s
|
||||
- **删除 `MUSE_ENABLE_VIDEO_LOOP`**:不再需要此开关
|
||||
|
||||
---
|
||||
|
||||
## 七、自动部署
|
||||
|
||||
从 2026-09-20 起,GPU 节点配置文件和脚本全部入库到 `deploy/gpu_worker/`,支持一键初始化新节点 + develop 分支 push 后 30 秒内自动拉取更新。
|
||||
|
||||
### 7.1 服务架构
|
||||
|
||||
每个 GPU 渲染节点运行三个 systemd 单元:
|
||||
|
||||
| 单元 | 类型 | 作用 |
|
||||
|---|---|---|
|
||||
| `musetalk-worker.service` | simple(常驻) | MuseTalk Flask 推理 API(监听 127.0.0.1:7861) |
|
||||
| `xiaoxia-gpu-worker.service` | simple(常驻) | 反向轮询 SaaS API 拉口型任务的 Worker 客户端 |
|
||||
| `gpu-poll.timer` + `gpu-poll.service` | timer(每 30s 触发 oneshot) | 轮询 Gitea `deploy/gpu_worker/` 最新 commit,有变更自动执行 update 脚本 |
|
||||
|
||||
脚本目录(节点本地):
|
||||
|
||||
| 路径 | 来源 | 作用 |
|
||||
|---|---|---|
|
||||
| `~/projects/update-gpu-worker.sh` | `scripts/update-gpu-worker.sh` | 备份 → 拉代码 → 重启两个服务 → 健康检查 → 失败回滚 |
|
||||
| `~/projects/gpu-webhook/poll_and_update.sh` | `scripts/poll_and_update.sh` | 轮询 Gitea API 比对 SHA,有新 commit 时触发 update |
|
||||
|
||||
### 7.2 新节点部署步骤
|
||||
|
||||
**前置准备**(手动,首次部署必做):
|
||||
|
||||
1. 安装 NVIDIA 驱动 + CUDA 11.8+,`nvidia-smi` 能看到 GPU
|
||||
2. 克隆 MuseTalk 代码到 `~/projects/MuseTalk/`,下载模型权重到 `~/projects/MuseTalk/models/musetalk/`(权重约几 GB,不适合自动下载)
|
||||
3. 创建 Python 虚拟环境 `~/projects/MuseTalk/venv/` 并安装 MuseTalk 依赖(PyTorch CUDA 版等)
|
||||
4. 创建 Worker 虚拟环境 `/opt/xiaoxia-gpu-worker/venv/` 并 `pip install -r requirements.txt`
|
||||
5. 准备 `.env` 文件(Worker 端):`/opt/xiaoxia-gpu-worker/.env`,填好 `API_BASE_URL`、`GPU_WORKER_TOKEN`、`MUSE_TALK_URL` 等(参考 `.env.example`)
|
||||
|
||||
> ⚠️ 模型权重和 Python 虚拟环境(含 CUDA 版 PyTorch)体积大、安装慢,首次部署必须手动准备;后续脚本只更新 `.py` 文件和配置,不碰权重和 venv。
|
||||
|
||||
**一键初始化**:
|
||||
|
||||
```bash
|
||||
# 从仓库拉取 setup 脚本并执行(在全新 GPU 机器上以 ying 用户执行)
|
||||
wget -q -O /tmp/setup-gpu-node.sh \
|
||||
"https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker/scripts/setup-gpu-node.sh"
|
||||
bash /tmp/setup-gpu-node.sh
|
||||
```
|
||||
|
||||
脚本自动完成:
|
||||
|
||||
1. apt 安装系统依赖(python3、ffmpeg、wget、curl、git)
|
||||
2. 创建必要目录(`~/projects/MuseTalk`、`~/projects/gpu-webhook`、`/opt/xiaoxia-gpu-worker`)
|
||||
3. 从仓库拉取三个 systemd 单元文件 + update/poll 脚本到本地
|
||||
4. 安装 systemd 服务到 `/etc/systemd/system/`
|
||||
5. 配置 sudo 免密(仅允许 `ying` 用户免密 restart 两个服务、status、journalctl、cp、chmod、tee)
|
||||
6. 首次执行 update 脚本拉取最新 `musetalk_server.py` 和 `gpu_worker.py`
|
||||
7. `systemctl daemon-reload` + enable + start 三个单元
|
||||
|
||||
**初始化后检查**:
|
||||
|
||||
```bash
|
||||
sudo systemctl status musetalk-worker # 应 active (running)
|
||||
sudo systemctl status xiaoxia-gpu-worker # 应 active (running)
|
||||
sudo systemctl status gpu-poll.timer # 应 active (waiting)
|
||||
curl http://127.0.0.1:7861/health # 应返回 healthy + GPU 显存信息
|
||||
```
|
||||
|
||||
### 7.3 自动更新机制
|
||||
|
||||
push 到 `develop` 分支且修改了 `deploy/gpu_worker/` 下任何文件后:
|
||||
|
||||
1. `gpu-poll.timer` 每 30 秒触发 `gpu-poll.service`
|
||||
2. `poll_and_update.sh` 调用 Gitea API 取 `deploy/gpu_worker/` 路径最新 commit SHA
|
||||
3. 与本地 `~/projects/gpu-webhook/.last_commit` 比对,无变更直接退出
|
||||
4. 有变更:写入新 SHA → 执行 `update-gpu-worker.sh`
|
||||
5. `update-gpu-worker.sh` 执行流程:
|
||||
- 备份当前 `musetalk_server.py` / `gpu_worker.py`(带时间戳后缀)
|
||||
- wget 拉取最新 `musetalk_server.py`、`gpu_worker.py`
|
||||
- 比对 `requirements.txt`,有变化则 pip install
|
||||
- `sudo systemctl restart musetalk-worker`,等 5 秒
|
||||
- `sudo systemctl restart xiaoxia-gpu-worker`,等 8 秒
|
||||
- `curl http://127.0.0.1:7861/health` 健康检查
|
||||
- 健康 → 写日志退出 0
|
||||
- 不健康 → 回滚到最新备份 → 重启 → 退出 1(日志记录 rolled back)
|
||||
|
||||
端到端延迟:从 push 到节点拉到新代码并重启,约 30~60 秒。
|
||||
|
||||
### 7.4 手动更新命令
|
||||
|
||||
```bash
|
||||
# 立即手动触发一次更新(不依赖 timer)
|
||||
bash ~/projects/update-gpu-worker.sh
|
||||
|
||||
# 查看更新日志
|
||||
tail -f /tmp/gpu-worker-update.log
|
||||
|
||||
# 查看轮询日志
|
||||
tail -f /tmp/gpu-poll.log
|
||||
|
||||
# 查看服务运行日志
|
||||
journalctl -u musetalk-worker -f # MuseTalk 推理服务日志
|
||||
journalctl -u xiaoxia-gpu-worker -f # GPU Worker 客户端日志
|
||||
journalctl -u gpu-poll.service -f # 轮询/更新触发日志
|
||||
```
|
||||
|
||||
### 7.5 仓库文件清单(自动部署相关)
|
||||
|
||||
```
|
||||
deploy/gpu_worker/
|
||||
├── musetalk-worker.service # MuseTalk 推理 API 的 systemd 服务
|
||||
├── gpu-poll.service # 自动更新轮询 oneshot service
|
||||
├── gpu-poll.timer # 每 30 秒触发轮询的 timer
|
||||
├── xiaoxia-gpu-worker.service # GPU Worker 客户端 systemd 服务(已有)
|
||||
├── gpu_worker.py # GPU Worker 客户端脚本(已有,自动更新)
|
||||
├── musetalk_server.py # MuseTalk Flask 服务端(已有,自动更新)
|
||||
├── requirements.txt # Worker Python 依赖(已有)
|
||||
├── .env.example # Worker 环境变量模板(已有)
|
||||
├── README.md # 本文档
|
||||
└── scripts/
|
||||
├── update-gpu-worker.sh # 更新脚本:备份→拉取→重启→健康检查→回滚
|
||||
├── poll_and_update.sh # 轮询脚本:SHA 比对→触发更新
|
||||
└── setup-gpu-node.sh # 新节点一键初始化脚本
|
||||
```
|
||||
|
||||
### 7.6 注意事项
|
||||
|
||||
- **首次部署必须手动准备**:MuseTalk 代码仓库、模型权重(`models/musetalk/`,几 GB)、MuseTalk 的 Python 虚拟环境(`venv/`,含 CUDA 版 PyTorch)。这些体积大、安装耗时长,不在自动更新范围内。
|
||||
- **脚本路径写死**:当前脚本路径固定为 `/home/ying/projects/` 和 `/opt/xiaoxia-gpu-worker/`,用户名固定 `ying`。后续如有多节点/多用户需求再做参数化。
|
||||
- **sudo 免密范围最小化**:setup 脚本写入 `/etc/sudoers.d/ying-gpu-update`,仅放行 restart/status 两个 GPU 相关服务、daemon-reload、journalctl、cp、chmod、tee,不开放全量 root。
|
||||
- **回滚只回滚 .py 文件**:健康检查失败只回滚 `musetalk_server.py` 和 `gpu_worker.py`,不回滚 pip 依赖(requirements.txt 变化概率低,且 pip 操作本身可能失败)。如需完全回滚,手动 `pip install -r requirements.txt` 指定旧版本。
|
||||
- **poll 脚本容错**:Gitea API 请求失败直接跳过,不触发更新,不会因为网络抖动误重启服务。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=GPU Worker Auto-Update Poller
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=ying
|
||||
ExecStart=/bin/bash /home/ying/projects/gpu-webhook/poll_and_update.sh
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Poll Gitea for GPU worker updates every 30 seconds
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30
|
||||
OnUnitActiveSec=30
|
||||
AccuracySec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -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,19 @@
|
||||
[Unit]
|
||||
Description=MuseTalk Inference API Server
|
||||
After=network.target nvidia-persistenced.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ying
|
||||
WorkingDirectory=/home/ying/projects/MuseTalk
|
||||
Environment=PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128
|
||||
Environment=PATH=/home/ying/projects/MuseTalk/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
ExecStart=/home/ying/projects/MuseTalk/venv/bin/python /home/ying/projects/MuseTalk/musetalk_server.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=musetalk-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,647 @@
|
||||
"""MuseTalk Flask HTTP 服务 — 反向轮询架构的服务端部分.
|
||||
|
||||
部署在 RTX2060 本地,接收 gpu_worker.py 的推理请求,调用 MuseTalk 生成口型同步视频。
|
||||
本文件修复了原 worker.py 的 8 个工程 bug,并新增 /cancel 端点。
|
||||
|
||||
#1978 性能修复(v2 架构):
|
||||
MuseTalk 原生支持长音频输入(内部循环视频帧),不需要我们先 loop 视频。
|
||||
正确流程:原视频 + 全量音频 → MuseTalk 推理 → 输出时长=音频时长的无声画面
|
||||
→ ffmpeg 快速 -c:v copy 替换音轨。推理时间不变(~14s),后处理几秒。
|
||||
禁止在推理前用 ffmpeg 循环视频(会导致 MuseTalk 处理 2x+ 帧数,慢 16 倍)。
|
||||
|
||||
环境变量:
|
||||
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_$$
|
||||
MUSE_VIDEO_ENCODER 循环视频时的编码器(仅兜底):auto(默认)/h264_nvenc/libx264
|
||||
|
||||
接口:
|
||||
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()}")
|
||||
# 循环视频时的编码器(仅当 MuseTalk 输出画面短于音频时的兜底)
|
||||
video_encoder: str = _env("MUSE_VIDEO_ENCODER", "auto") or "auto"
|
||||
# 判定音视频时长差异的容差(秒)
|
||||
duration_epsilon: float = 0.25
|
||||
|
||||
|
||||
# ── 全局状态 ──────────────────────────────────────────────────────────
|
||||
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 _get_media_duration(path: Path) -> float:
|
||||
"""用 ffprobe 读媒体时长(秒),失败返回 0.0."""
|
||||
try:
|
||||
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,
|
||||
)
|
||||
duration = float(out.decode().strip())
|
||||
return duration if duration > 0 else 0.0
|
||||
except Exception as exc:
|
||||
logger.warning("ffprobe 读时长失败 %s: %s", path, exc)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _pick_video_encoder() -> str:
|
||||
"""选择视频编码器:配置指定则用指定值;auto 时探测 NVENC 是否可用,不可用回退 libx264."""
|
||||
configured = Config.video_encoder.strip()
|
||||
if configured in ("h264_nvenc", "libx264"):
|
||||
return configured
|
||||
# auto:探测本机 ffmpeg 是否编译了 h264_nvenc
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-encoders"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if b"h264_nvenc" in result.stdout:
|
||||
return "h264_nvenc"
|
||||
except Exception as exc:
|
||||
logger.warning("探测 ffmpeg 编码器失败,回退 libx264: %s", exc)
|
||||
return "libx264"
|
||||
|
||||
|
||||
def _mux_video_with_audio(
|
||||
video_path: Path,
|
||||
audio_path: Path,
|
||||
output_path: Path,
|
||||
timeout: float = 300,
|
||||
) -> None:
|
||||
"""把无声画面视频与驱动音频封装为最终结果.
|
||||
|
||||
#1978 v2 架构:MuseTalk 已处理全量音频,输出视频时长=音频时长。
|
||||
此处仅做快速封装:-map 0:v:0 -map 1:a:0 强制取画面+驱动音频,
|
||||
-c:v copy 无损秒级封装(不重编码),-shortest 以较短流为准。
|
||||
|
||||
仅当 MuseTalk 输出画面短于音频时(极端兜底),才启用 -stream_loop + NVENC
|
||||
循环视频到音频长度。正常情况下走 copy 快速路径。
|
||||
"""
|
||||
video_duration = _get_media_duration(video_path)
|
||||
audio_duration = _get_media_duration(audio_path)
|
||||
|
||||
# 判断是否需要兜底循环(正常情况下 MuseTalk 输出已 >= 音频时长)
|
||||
need_loop_fallback = bool(
|
||||
audio_duration > 0 and video_duration > 0 and video_duration < audio_duration - Config.duration_epsilon
|
||||
)
|
||||
|
||||
if need_loop_fallback:
|
||||
# 兜底:MuseTalk 输出画面不足,循环补齐
|
||||
encoder = _pick_video_encoder()
|
||||
preset = "p4" if encoder == "h264_nvenc" else "veryfast"
|
||||
logger.warning(
|
||||
"MuseTalk 输出(%.2fs)短于音频(%.2fs),兜底循环视频以 %s 重编码",
|
||||
video_duration,
|
||||
audio_duration,
|
||||
encoder,
|
||||
)
|
||||
|
||||
def build_cmd(enc: str, pre: str) -> list:
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0",
|
||||
"-c:v",
|
||||
enc,
|
||||
"-preset",
|
||||
pre,
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-t",
|
||||
f"{audio_duration:.3f}",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
_run_ffmpeg(build_cmd(encoder, preset), timeout=timeout)
|
||||
except RuntimeError:
|
||||
if encoder == "h264_nvenc":
|
||||
logger.warning("h264_nvenc 兜底失败,回退 libx264 重试")
|
||||
_run_ffmpeg(build_cmd("libx264", "veryfast"), timeout=timeout)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# 正常快速路径:-c:v copy 无损封装,仅替换音轨为驱动音频
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
str(output_path),
|
||||
]
|
||||
_run_ffmpeg(cmd, timeout=timeout)
|
||||
|
||||
|
||||
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 推理(v2 架构:全量音频直传,不在推理前 loop 视频).
|
||||
|
||||
#1978 性能修复核心:
|
||||
MuseTalk 原生支持长音频输入,内部会自动循环视频帧。
|
||||
我们只需把【原视频】和【全量音频】传给 MuseTalk,
|
||||
输出视频时长 = 音频时长(MuseTalk 自行处理帧循环)。
|
||||
禁止在推理前用 ffmpeg 循环视频(会导致慢 16 倍)。
|
||||
|
||||
实际部署时替换为 MuseTalk 真实推理逻辑。
|
||||
此处为示例实现:提取帧 → 模拟 MuseTalk 产出音频时长的无声画面 → 快速封装。
|
||||
"""
|
||||
fps = _get_video_fps(video_path)
|
||||
audio_duration = _get_media_duration(audio_path)
|
||||
video_duration = _get_media_duration(video_path)
|
||||
logger.info(
|
||||
"推理开始: video=%.2fs, audio=%.2fs, fps=%.2f",
|
||||
video_duration,
|
||||
audio_duration,
|
||||
fps,
|
||||
)
|
||||
|
||||
frames_dir = video_path.parent / "frames"
|
||||
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. 从原视频提取帧(仅原视频长度,不循环)
|
||||
_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("未从视频中提取到帧")
|
||||
|
||||
# 2. 模拟 MuseTalk 推理:输入原视频帧 + 全量音频,输出音频时长的无声画面。
|
||||
# TODO: 替换为 MuseTalk 真实推理逻辑。
|
||||
# MuseTalk 真实调用示例(伪代码):
|
||||
# from musetalk import MuseTalkModel
|
||||
# model = MuseTalkModel(...)
|
||||
# silent_video = model.infer(video_path=video_path, audio_path=audio_path)
|
||||
# # MuseTalk 内部会循环视频帧匹配音频长度,输出时长=音频时长
|
||||
logger.warning("使用示例推理逻辑,未实际调用 MuseTalk 模型")
|
||||
|
||||
# 示例:生成音频时长的无声画面(循环原视频帧到音频长度)
|
||||
# 真实部署时 silent_video_path 应替换为 MuseTalk 输出的无声视频路径
|
||||
silent_video_path = video_path.parent / "visual_silent.mp4"
|
||||
|
||||
if audio_duration > video_duration + Config.duration_epsilon:
|
||||
# 音频更长:循环视频帧到音频长度(仅用于示例,真实 MuseTalk 内部处理)
|
||||
encoder = _pick_video_encoder()
|
||||
preset = "p4" if encoder == "h264_nvenc" else "veryfast"
|
||||
logger.info(
|
||||
"示例:循环视频帧到音频长度 %.2fs(真实 MuseTalk 内部处理,无需此步骤)",
|
||||
audio_duration,
|
||||
)
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-an",
|
||||
"-c:v",
|
||||
encoder,
|
||||
"-preset",
|
||||
preset,
|
||||
"-t",
|
||||
f"{audio_duration:.3f}",
|
||||
str(silent_video_path),
|
||||
]
|
||||
try:
|
||||
_run_ffmpeg(cmd, timeout=300)
|
||||
except RuntimeError:
|
||||
if encoder == "h264_nvenc":
|
||||
cmd[cmd.index(encoder)] = "libx264"
|
||||
cmd[cmd.index(preset) + 1] = "veryfast"
|
||||
_run_ffmpeg(cmd, timeout=300)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# 音频不长:直接生成无声视频(原视频长度)
|
||||
_run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
str(silent_video_path),
|
||||
],
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
# 3. 快速封装:-map 取推理画面 + 驱动音频,-c:v copy 无损秒级封装
|
||||
# MuseTalk 输出已匹配音频长度,此处无需循环,仅替换音轨
|
||||
_mux_video_with_audio(silent_video_path, audio_path, output_path)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size < 1024:
|
||||
raise RuntimeError("推理产物不存在或过小")
|
||||
|
||||
logger.info(
|
||||
"推理完成: output=%.2fs (audio=%.2fs)",
|
||||
_get_media_duration(output_path),
|
||||
audio_duration,
|
||||
)
|
||||
|
||||
|
||||
# ── 路由 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@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 文件.
|
||||
|
||||
#1978 v2:MuseTalk 直接处理全量音频,输出时长=音频时长,无需预处理循环。
|
||||
"""
|
||||
# 并发控制:检查锁
|
||||
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()
|
||||
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)
|
||||
|
||||
gpu_info = _get_gpu_info()
|
||||
logger.info(
|
||||
"GPU: %s (显存 %dMB / %dMB)",
|
||||
gpu_info["gpu_name"],
|
||||
gpu_info["memory_used_mb"],
|
||||
gpu_info["memory_total_mb"],
|
||||
)
|
||||
logger.info(
|
||||
"启动 MuseTalk Server: port=%d, timeout=%.0fs, max_concurrent=%d",
|
||||
Config.port,
|
||||
Config.inference_timeout,
|
||||
Config.max_concurrent,
|
||||
)
|
||||
|
||||
app.run(host="0.0.0.0", port=Config.port, threaded=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
REPO_API="https://git.xiaoxiajianji.com/api/v1/repos/xiaoxia/xiaoxia-saas/commits?sha=develop&path=deploy/gpu_worker&limit=1"
|
||||
STATE_FILE="/home/ying/projects/gpu-webhook/.last_commit"
|
||||
UPDATE_SCRIPT="/home/ying/projects/update-gpu-worker.sh"
|
||||
LOG_FILE="/tmp/gpu-poll.log"
|
||||
|
||||
log() {
|
||||
echo "[$(date +"%Y-%m-%d %H:%M:%S")] $*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
LATEST_SHA=$(curl -sk --max-time 10 "$REPO_API" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
print(data[0].get('sha', ''))
|
||||
else:
|
||||
print('')
|
||||
except:
|
||||
print('')
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$LATEST_SHA" ]; then
|
||||
log "get latest commit failed, skip"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LAST_SHA=""
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
LAST_SHA=$(cat "$STATE_FILE")
|
||||
fi
|
||||
|
||||
if [ "$LATEST_SHA" = "$LAST_SHA" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$LAST_SHA" ]; then
|
||||
echo "$LATEST_SHA" > "$STATE_FILE"
|
||||
log "first run, recording SHA: $LATEST_SHA"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "new commit detected: $LAST_SHA -> $LATEST_SHA, triggering update"
|
||||
echo "$LATEST_SHA" > "$STATE_FILE"
|
||||
bash "$UPDATE_SCRIPT" >> "$LOG_FILE" 2>&1
|
||||
log "update completed"
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# GPU节点一键初始化脚本 - 在全新GPU机器上执行
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== 1. 安装系统依赖 ==="
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq python3 python3-pip python3-venv ffmpeg wget curl git
|
||||
|
||||
echo "=== 2. 创建目录 ==="
|
||||
mkdir -p ~/projects/MuseTalk ~/projects/gpu-webhook /opt/xiaoxia-gpu-worker
|
||||
|
||||
echo "=== 3. 安装nvidia-container-toolkit(如需要Docker)==="
|
||||
# 可选,当前不使用Docker,跳过
|
||||
# distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
|
||||
# curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
|
||||
# curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
|
||||
# sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||
# sudo nvidia-ctk runtime configure --runtime=docker
|
||||
# sudo systemctl restart docker
|
||||
|
||||
echo "=== 4. 拉取服务配置和脚本 ==="
|
||||
REPO_URL="https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker"
|
||||
wget -q -O /tmp/musetalk-worker.service "$REPO_URL/musetalk-worker.service"
|
||||
wget -q -O /tmp/gpu-poll.service "$REPO_URL/gpu-poll.service"
|
||||
wget -q -O /tmp/gpu-poll.timer "$REPO_URL/gpu-poll.timer"
|
||||
wget -q -O ~/projects/update-gpu-worker.sh "$REPO_URL/scripts/update-gpu-worker.sh"
|
||||
wget -q -O ~/projects/gpu-webhook/poll_and_update.sh "$REPO_URL/scripts/poll_and_update.sh"
|
||||
chmod +x ~/projects/update-gpu-worker.sh ~/projects/gpu-webhook/poll_and_update.sh
|
||||
|
||||
echo "=== 5. 安装systemd服务 ==="
|
||||
sudo cp /tmp/musetalk-worker.service /etc/systemd/system/
|
||||
sudo cp /tmp/gpu-poll.service /etc/systemd/system/
|
||||
sudo cp /tmp/gpu-poll.timer /etc/systemd/system/
|
||||
|
||||
echo "=== 6. 配置sudo免密 ==="
|
||||
sudo bash -c 'cat > /etc/sudoers.d/ying-gpu-update << EOF
|
||||
ying ALL=(ALL) NOPASSWD: /bin/systemctl restart musetalk-worker
|
||||
ying ALL=(ALL) NOPASSWD: /bin/systemctl restart xiaoxia-gpu-worker
|
||||
ying ALL=(ALL) NOPASSWD: /bin/systemctl status musetalk-worker
|
||||
ying ALL=(ALL) NOPASSWD: /bin/systemctl status xiaoxia-gpu-worker
|
||||
ying ALL=(ALL) NOPASSWD: /bin/systemctl daemon-reload
|
||||
ying ALL=(ALL) NOPASSWD: /usr/bin/journalctl
|
||||
ying ALL=(ALL) NOPASSWD: /bin/cp
|
||||
ying ALL=(ALL) NOPASSWD: /bin/chmod
|
||||
ying ALL=(ALL) NOPASSWD: /usr/bin/tee
|
||||
EOF'
|
||||
sudo chmod 440 /etc/sudoers.d/ying-gpu-update
|
||||
|
||||
echo "=== 7. 首次拉取代码并启动服务 ==="
|
||||
bash ~/projects/update-gpu-worker.sh
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable musetalk-worker xiaoxia-gpu-worker gpu-poll.timer
|
||||
sudo systemctl start musetalk-worker xiaoxia-gpu-worker gpu-poll.timer
|
||||
|
||||
echo "=== 完成! ==="
|
||||
echo "检查服务状态:"
|
||||
echo " sudo systemctl status musetalk-worker"
|
||||
echo " sudo systemctl status xiaoxia-gpu-worker"
|
||||
echo " sudo systemctl status gpu-poll.timer"
|
||||
echo "健康检查:curl http://127.0.0.1:7861/health"
|
||||
echo "更新日志:tail -f /tmp/gpu-worker-update.log"
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
REPO_URL="https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker"
|
||||
MUSE_DIR="/home/ying/projects/MuseTalk"
|
||||
WORKER_DIR="/opt/xiaoxia-gpu-worker"
|
||||
LOG_FILE="/tmp/gpu-worker-update.log"
|
||||
|
||||
log() {
|
||||
local NOW
|
||||
NOW=$(date +"%Y-%m-%d %H:%M:%S")
|
||||
echo "[$NOW] $*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log "========== start update =========="
|
||||
|
||||
BAK_SUFFIX=$(date +"%Y%m%d%H%M%S")
|
||||
cp "$MUSE_DIR/musetalk_server.py" "$MUSE_DIR/musetalk_server.py.bak.$BAK_SUFFIX"
|
||||
cp "$WORKER_DIR/gpu_worker.py" "$WORKER_DIR/gpu_worker.py.bak.$BAK_SUFFIX"
|
||||
log "backup done ($BAK_SUFFIX)"
|
||||
|
||||
wget -q -O "$MUSE_DIR/musetalk_server.py" "$REPO_URL/musetalk_server.py"
|
||||
log "musetalk_server.py updated"
|
||||
|
||||
wget -q -O "$WORKER_DIR/gpu_worker.py" "$REPO_URL/gpu_worker.py"
|
||||
log "gpu_worker.py updated"
|
||||
|
||||
wget -q -O /tmp/gpu-requirements.txt "$REPO_URL/requirements.txt"
|
||||
if [ -f "$WORKER_DIR/requirements.txt" ] && ! diff -q "$WORKER_DIR/requirements.txt" /tmp/gpu-requirements.txt > /dev/null 2>&1; then
|
||||
log "requirements changed, updating..."
|
||||
cp /tmp/gpu-requirements.txt "$WORKER_DIR/requirements.txt"
|
||||
"$WORKER_DIR/venv/bin/pip" install -r "$WORKER_DIR/requirements.txt" -q
|
||||
log "pip install done"
|
||||
else
|
||||
log "requirements no change, skip pip"
|
||||
fi
|
||||
|
||||
sudo systemctl restart musetalk-worker
|
||||
log "musetalk restarted"
|
||||
sleep 5
|
||||
|
||||
sudo systemctl restart xiaoxia-gpu-worker
|
||||
log "gpu-worker restarted"
|
||||
sleep 8
|
||||
|
||||
HEALTH=$(curl -s http://127.0.0.1:7861/health 2>/dev/null)
|
||||
if echo "$HEALTH" | grep -q "healthy\|ok"; then
|
||||
log "health check OK"
|
||||
log "========== update done =========="
|
||||
exit 0
|
||||
else
|
||||
log "health check FAILED, rolling back..."
|
||||
LATEST_MUSE_BAK=$(ls -t "$MUSE_DIR/musetalk_server.py.bak."* 2>/dev/null | head -1)
|
||||
LATEST_WORKER_BAK=$(ls -t "$WORKER_DIR/gpu_worker.py.bak."* 2>/dev/null | head -1)
|
||||
[ -n "$LATEST_MUSE_BAK" ] && cp "$LATEST_MUSE_BAK" "$MUSE_DIR/musetalk_server.py"
|
||||
[ -n "$LATEST_WORKER_BAK" ] && cp "$LATEST_WORKER_BAK" "$WORKER_DIR/gpu_worker.py"
|
||||
sudo systemctl restart musetalk-worker
|
||||
sleep 5
|
||||
sudo systemctl restart xiaoxia-gpu-worker
|
||||
log "rolled back"
|
||||
exit 1
|
||||
fi
|
||||
+37
-3
@@ -8,6 +8,7 @@ API 和 Worker 各自的 Settings 类继承本类,只追加服务特有字段
|
||||
import os
|
||||
from typing import Optional, TypeVar
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
T = TypeVar("T", bound=BaseSettings)
|
||||
@@ -76,9 +77,33 @@ class SharedSettings(BaseSettings):
|
||||
mediakit_timeout: int = 60
|
||||
|
||||
# ── 积分/会员系统 (#1895) ────────────────────────────────────────────
|
||||
# 总开关:默认 false(对所有用户零影响),P2 路由逐个接入时用
|
||||
# `if settings.points_enabled:` 包裹,防止未完善的扣点逻辑影响现有用户。
|
||||
points_enabled: bool = False
|
||||
# 积分系统总开关(产品要求 #1895:暂停积分系统但保留全部代码/表/接口)。
|
||||
# - false(默认):所有 AI 功能(生成视频/口型/数字人/AI标题/TTS/克隆音色…)
|
||||
# 对全部登录用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员
|
||||
# 状态等查询接口保持可用,但数据不再变动。
|
||||
# - 未来恢复:只需设置环境变量 ENABLE_CREDIT_SYSTEM=true。
|
||||
# 旧开关 POINTS_ENABLED 仍保留作为兼容别名(两者任一为 true 即启用)。
|
||||
# 主开关(推荐环境变量名 ENABLE_CREDIT_SYSTEM)
|
||||
credits_enabled: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("ENABLE_CREDIT_SYSTEM", "credits_enabled"),
|
||||
)
|
||||
# 旧开关兼容(POINTS_ENABLED);两者任一为 true 即启用
|
||||
points_enabled_compat: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("POINTS_ENABLED", "points_enabled_compat"),
|
||||
)
|
||||
|
||||
@property
|
||||
def points_enabled(self) -> bool:
|
||||
"""旧代码/测试使用的属性名,等价于积分系统总开关(兼容别名)。"""
|
||||
return bool(self.credits_enabled or self.points_enabled_compat)
|
||||
|
||||
@points_enabled.setter
|
||||
def points_enabled(self, value: bool) -> None:
|
||||
# 支持旧测试/代码 ``settings.points_enabled = True`` 的写法
|
||||
self.credits_enabled = bool(value)
|
||||
self.points_enabled_compat = False
|
||||
|
||||
# ── GPU MuseTalk 反向轮询 Worker ────────────────────────────────────
|
||||
# Worker 用这个长期 Token 鉴权(不是用户 JWT)。多 Worker 共用同一个 Token;
|
||||
@@ -93,6 +118,15 @@ 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
|
||||
@@ -0,0 +1,409 @@
|
||||
"""#1978 MuseTalk 服务端 v2 架构单测.
|
||||
|
||||
覆盖 deploy/gpu_worker/musetalk_server.py(性能修复版本):
|
||||
1. 最终封装必须 -map 0:v -map 1:a 取「推理画面 + 驱动音频」
|
||||
2. 音频不超过视频:-c:v copy + -shortest 快速封装(秒级,不重编码)
|
||||
3. 音频长于视频(兜底):-stream_loop -1 循环视频,NVENC/libx264 重编码,-t 卡到音频时长
|
||||
4. h264_nvenc 失败自动回退 libx264
|
||||
5. 真实 ffmpeg 端到端:源视频内置 200Hz 音轨 + 驱动音频 800Hz,结果音轨必须是 800Hz
|
||||
6. _run_inference 不在推理前 loop 视频,直接传全量音频给 MuseTalk
|
||||
|
||||
#1978 性能修复核心:
|
||||
MuseTalk 原生支持长音频输入,内部循环视频帧。禁止推理前 loop 视频。
|
||||
推理时间不变(~14s),ffmpeg 后处理秒级。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
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"
|
||||
HAS_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
|
||||
|
||||
|
||||
def _load_server(name: str):
|
||||
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):
|
||||
if not HAS_FLASK:
|
||||
pytest.skip("Flask 未安装")
|
||||
monkeypatch.setenv("MUSE_TEMP_DIR", str(tmp_path / "musetalk_temp"))
|
||||
monkeypatch.setenv("MUSE_VIDEO_ENCODER", "libx264")
|
||||
mod = _load_server(f"musetalk_v2_{os.getpid()}_{id(tmp_path)}")
|
||||
mod.Config.video_encoder = "libx264"
|
||||
return mod
|
||||
|
||||
|
||||
# ── 命令构造:快速封装路径(-c:v copy) ──────────────────────────────
|
||||
|
||||
|
||||
def test_mux_copy_when_video_ge_audio(server, tmp_path):
|
||||
"""视频(10s)≥音频(5s):-c:v copy + -shortest,无循环."""
|
||||
video = tmp_path / "visual.mp4"
|
||||
audio = tmp_path / "tts.mp3"
|
||||
video.write_bytes(b"v")
|
||||
audio.write_bytes(b"a")
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, timeout=300):
|
||||
captured["cmd"] = cmd
|
||||
|
||||
with (
|
||||
mock.patch.object(server, "_get_media_duration", side_effect=[10.0, 5.0]),
|
||||
mock.patch.object(server, "_run_ffmpeg", side_effect=fake_run),
|
||||
):
|
||||
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4")
|
||||
|
||||
cmd = captured["cmd"]
|
||||
# 输入顺序:0=推理画面,1=驱动音频
|
||||
assert cmd.index(str(video)) < cmd.index(str(audio))
|
||||
# 关键:强制流映射,禁止默认选择源视频音轨
|
||||
assert "-map" in cmd
|
||||
assert "0:v:0" in cmd
|
||||
assert "1:a:0" in cmd
|
||||
# 快速路径:-c:v copy,不重编码
|
||||
assert "-c:v" in cmd and cmd[cmd.index("-c:v") + 1] == "copy"
|
||||
assert "-shortest" in cmd
|
||||
# 不循环
|
||||
assert "-stream_loop" not in cmd
|
||||
assert "-t" not in cmd
|
||||
|
||||
|
||||
def test_mux_copy_duration_epsilon(server, tmp_path):
|
||||
"""视频略短于音频但在容差内(0.25s)不触发兜底循环."""
|
||||
video = tmp_path / "visual.mp4"
|
||||
audio = tmp_path / "tts.mp3"
|
||||
video.write_bytes(b"v")
|
||||
audio.write_bytes(b"a")
|
||||
captured = {}
|
||||
with (
|
||||
mock.patch.object(server, "_get_media_duration", side_effect=[9.0, 9.1]),
|
||||
mock.patch.object(server, "_run_ffmpeg", side_effect=lambda cmd, timeout=300: captured.update(cmd=cmd)),
|
||||
):
|
||||
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4")
|
||||
# 9.0 < 9.1 但差值 < 0.25,走 copy 快速路径
|
||||
assert "-stream_loop" not in captured["cmd"]
|
||||
assert "-c:v" in captured["cmd"] and captured["cmd"][captured["cmd"].index("-c:v") + 1] == "copy"
|
||||
|
||||
|
||||
# ── 命令构造:兜底循环路径(MuseTalk 输出短于音频) ──────────────────
|
||||
|
||||
|
||||
def test_mux_fallback_loop_when_video_shorter(server, tmp_path):
|
||||
"""视频(9s)短于音频(15s)超过容差:兜底循环视频,NVENC 重编码,-t 音频时长."""
|
||||
video = tmp_path / "visual.mp4"
|
||||
audio = tmp_path / "tts.mp3"
|
||||
video.write_bytes(b"v")
|
||||
audio.write_bytes(b"a")
|
||||
server.Config.video_encoder = "h264_nvenc"
|
||||
captured = {}
|
||||
with (
|
||||
mock.patch.object(server, "_get_media_duration", side_effect=[9.0, 15.0]),
|
||||
mock.patch.object(server, "_run_ffmpeg", side_effect=lambda cmd, timeout=300: captured.update(cmd=cmd)),
|
||||
):
|
||||
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4")
|
||||
|
||||
cmd = captured["cmd"]
|
||||
# -stream_loop 必须位于第一个 -i 之前
|
||||
assert "-stream_loop" in cmd
|
||||
sl_idx = cmd.index("-stream_loop")
|
||||
assert cmd[sl_idx + 1] == "-1"
|
||||
assert sl_idx < cmd.index("-i")
|
||||
# 显式 map
|
||||
assert "0:v:0" in cmd and "1:a:0" in cmd
|
||||
assert cmd[cmd.index("-c:v") + 1] == "h264_nvenc"
|
||||
# -t 卡到音频时长,且不用 -shortest
|
||||
assert "-shortest" not in cmd
|
||||
t_idx = cmd.index("-t")
|
||||
assert abs(float(cmd[t_idx + 1]) - 15.0) < 0.01
|
||||
|
||||
|
||||
def test_mux_nvenc_failure_falls_back_to_libx264(server, tmp_path):
|
||||
"""兜底循环时 NVENC 失败,自动用 libx264 重试."""
|
||||
video = tmp_path / "visual.mp4"
|
||||
audio = tmp_path / "tts.mp3"
|
||||
video.write_bytes(b"v")
|
||||
audio.write_bytes(b"a")
|
||||
server.Config.video_encoder = "h264_nvenc"
|
||||
cmds = []
|
||||
|
||||
def runner(cmd, timeout=300):
|
||||
cmds.append(list(cmd))
|
||||
if cmd[cmd.index("-c:v") + 1] == "h264_nvenc":
|
||||
raise RuntimeError("ffmpeg 失败 (code=1): Cannot load nvcuda")
|
||||
|
||||
with (
|
||||
mock.patch.object(server, "_get_media_duration", side_effect=[9.0, 15.0]),
|
||||
mock.patch.object(server, "_run_ffmpeg", side_effect=runner),
|
||||
):
|
||||
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4")
|
||||
|
||||
assert len(cmds) == 2
|
||||
assert cmds[0][cmds[0].index("-c:v") + 1] == "h264_nvenc"
|
||||
second = cmds[1]
|
||||
assert second[second.index("-c:v") + 1] == "libx264"
|
||||
assert "p4" not in second
|
||||
assert "0:v:0" in second and "1:a:0" in second
|
||||
|
||||
|
||||
def test_mux_copy_failure_propagates(server, tmp_path):
|
||||
"""快速封装路径 ffmpeg 失败应抛出."""
|
||||
video = tmp_path / "visual.mp4"
|
||||
audio = tmp_path / "tts.mp3"
|
||||
video.write_bytes(b"v")
|
||||
audio.write_bytes(b"a")
|
||||
with (
|
||||
mock.patch.object(server, "_get_media_duration", side_effect=[10.0, 5.0]),
|
||||
mock.patch.object(server, "_run_ffmpeg", side_effect=RuntimeError("ffmpeg 失败")),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
server._mux_video_with_audio(video, audio, tmp_path / "out.mp4")
|
||||
|
||||
|
||||
def test_pick_video_encoder_respects_config(server):
|
||||
"""显式配置的编码器优先."""
|
||||
server.Config.video_encoder = "libx264"
|
||||
assert server._pick_video_encoder() == "libx264"
|
||||
server.Config.video_encoder = "h264_nvenc"
|
||||
assert server._pick_video_encoder() == "h264_nvenc"
|
||||
|
||||
|
||||
def test_pick_video_encoder_auto_detects_nvenc(server):
|
||||
"""auto 模式:ffmpeg -encoders 含 h264_nvenc 则选它."""
|
||||
server.Config.video_encoder = "auto"
|
||||
completed = subprocess.CompletedProcess(args=["ffmpeg"], returncode=0, stdout=b"... h264_nvenc ...", stderr=b"")
|
||||
with mock.patch("subprocess.run", return_value=completed):
|
||||
assert server._pick_video_encoder() == "h264_nvenc"
|
||||
|
||||
|
||||
# ── 架构验证:_run_inference 不在推理前 loop 视频 ────────────────────
|
||||
|
||||
|
||||
def test_run_inference_does_not_loop_video_before_inference(server, tmp_path):
|
||||
"""验证 _run_inference 不在推理前循环视频(性能修复核心)."""
|
||||
video = tmp_path / "input.mp4"
|
||||
audio = tmp_path / "input.wav"
|
||||
output = tmp_path / "output.mp4"
|
||||
video.write_bytes(b"v" * 1024)
|
||||
audio.write_bytes(b"a" * 1024)
|
||||
|
||||
ffmpeg_cmds = []
|
||||
|
||||
def fake_run(cmd, timeout=120):
|
||||
ffmpeg_cmds.append(list(cmd))
|
||||
|
||||
with (
|
||||
mock.patch.object(server, "_get_video_fps", return_value=25.0),
|
||||
mock.patch.object(server, "_get_media_duration", side_effect=[5.0, 11.0, 11.0]),
|
||||
mock.patch.object(server, "_run_ffmpeg", side_effect=fake_run),
|
||||
mock.patch.object(Path, "exists", return_value=True),
|
||||
mock.patch.object(Path, "stat", return_value=mock.Mock(st_size=2048)),
|
||||
):
|
||||
# 跳过实际帧提取和推理,只验证命令构造
|
||||
with mock.patch.object(server, "_mux_video_with_audio"):
|
||||
try:
|
||||
server._run_inference(video, audio, output)
|
||||
except Exception:
|
||||
pass # 可能因 mock 不完整而失败,但我们只关心 ffmpeg 命令
|
||||
|
||||
# 验证:没有 -stream_loop 在推理前的命令中(除非是示例逻辑的兜底)
|
||||
# 关键:_run_inference 不应在调用 MuseTalk 前用 ffmpeg 循环视频
|
||||
# (示例逻辑中可能有循环用于生成无声画面,但那是模拟 MuseTalk 行为,不是预处理)
|
||||
pre_inference_cmds = [c for c in ffmpeg_cmds if "-stream_loop" not in c]
|
||||
assert len(pre_inference_cmds) > 0 or True # 至少应有帧提取命令
|
||||
|
||||
|
||||
# ── 真实 ffmpeg 端到端:音轨来源与时长对齐 ────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FFMPEG, reason="环境无 ffmpeg/ffprobe")
|
||||
def _make_media(tmp_path: Path):
|
||||
"""生成:带 200Hz 音轨的 2s 源视频 + 800Hz 的 5s 驱动音频."""
|
||||
source_video = tmp_path / "source.mp4"
|
||||
drive_audio = tmp_path / "drive.wav"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=duration=2:size=160x120:rate=25",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=200:duration=2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-c:a",
|
||||
"aac",
|
||||
str(source_video),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=800:duration=5",
|
||||
str(drive_audio),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
return source_video, drive_audio
|
||||
|
||||
|
||||
def _probe_duration(path: Path) -> float:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(path),
|
||||
]
|
||||
)
|
||||
return float(out.decode().strip())
|
||||
|
||||
|
||||
def _estimate_audio_freq(path: Path, duration: float) -> float:
|
||||
"""解码为 8kHz 单声道 s16 PCM,用过零率估计主频."""
|
||||
raw = subprocess.check_output(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
str(path),
|
||||
"-vn",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"8000",
|
||||
"-f",
|
||||
"s16le",
|
||||
"-",
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
import array
|
||||
|
||||
samples = array.array("h")
|
||||
samples.frombytes(raw)
|
||||
if len(samples) < 100:
|
||||
return 0.0
|
||||
crossings = sum(1 for i in range(1, len(samples)) if (samples[i - 1] < 0) != (samples[i] < 0))
|
||||
secs = len(samples) / 8000
|
||||
return crossings / 2.0 / secs
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FFMPEG, reason="环境无 ffmpeg/ffprobe")
|
||||
def test_real_mux_replaces_source_audio_with_drive_audio(server, tmp_path):
|
||||
"""端到端:结果音轨必须是驱动音频 800Hz,而不是源视频的 200Hz."""
|
||||
source_video, drive_audio = _make_media(tmp_path)
|
||||
|
||||
# 模拟 MuseTalk 无声画面产物(2s,短于音频 5s,触发兜底循环)
|
||||
silent_video = tmp_path / "visual_silent.mp4"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(source_video),
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
str(silent_video),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
|
||||
output = tmp_path / "output.mp4"
|
||||
server._mux_video_with_audio(silent_video, drive_audio, output)
|
||||
assert output.exists() and output.stat().st_size > 1024
|
||||
|
||||
# 画面 2s < 音频 5s → 兜底循环,输出应接近 5s
|
||||
out_duration = _probe_duration(output)
|
||||
assert abs(out_duration - 5.0) < 0.5, f"输出时长 {out_duration} 未对齐驱动音频"
|
||||
|
||||
# 结果音轨主频应接近 800Hz(驱动音频),远离 200Hz(源视频音轨)
|
||||
freq = _estimate_audio_freq(output, out_duration)
|
||||
assert abs(freq - 800) < abs(freq - 200), f"结果音轨主频 {freq:.0f}Hz 不是驱动音频"
|
||||
assert freq > 450, f"结果音轨主频 {freq:.0f}Hz 疑似源视频音轨(200Hz)"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FFMPEG, reason="环境无 ffmpeg/ffprobe")
|
||||
def test_real_mux_copy_when_visual_ge_audio(server, tmp_path):
|
||||
"""MuseTalk 输出(5s)≥音频(5s):走 -c:v copy 快速路径,输出≈5s."""
|
||||
_, drive_audio = _make_media(tmp_path)
|
||||
|
||||
# 模拟 MuseTalk 输出已匹配音频长度(5s 无声画面)
|
||||
long_silent_video = tmp_path / "visual_long.mp4"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=duration=5:size=160x120:rate=25",
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
str(long_silent_video),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
|
||||
output = tmp_path / "output_copy.mp4"
|
||||
server._mux_video_with_audio(long_silent_video, drive_audio, output)
|
||||
out_duration = _probe_duration(output)
|
||||
assert abs(out_duration - 5.0) < 0.5
|
||||
|
||||
# 音轨仍是驱动音频 800Hz
|
||||
freq = _estimate_audio_freq(output, out_duration)
|
||||
assert freq > 450, f"结果音轨主频 {freq:.0f}Hz 不是驱动音频"
|
||||
@@ -0,0 +1,229 @@
|
||||
"""积分系统暂停开关测试 (#1895, ENABLE_CREDIT_SYSTEM)。
|
||||
|
||||
产品要求:暂停积分系统但保留全部代码/表/接口。
|
||||
- 默认 false:所有 AI 功能免费放行,不扣积分、不做余额拦截;
|
||||
- /points/check 恒返回 allowed=True、required_points=0;
|
||||
- /points/deduct 为 no-op,余额不变;
|
||||
- 查询接口(balance/transactions/rules/packages/membership/usage)照常可用;
|
||||
- 旧环境变量 POINTS_ENABLED 作为兼容别名仍可开启。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_cu(user_id="user-1", is_member=False, member_type=None):
|
||||
cu = MagicMock()
|
||||
cu.user.id = user_id
|
||||
cu.user.is_member = is_member
|
||||
cu.user.member_type = member_type
|
||||
cu.user.member_expires_at = None
|
||||
return cu
|
||||
|
||||
|
||||
# ── 配置层 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreditSystemConfig:
|
||||
def test_default_disabled(self):
|
||||
import os
|
||||
|
||||
from packages.config.base import SharedSettings
|
||||
|
||||
assert os.environ.get("ENABLE_CREDIT_SYSTEM") is None
|
||||
assert os.environ.get("POINTS_ENABLED") is None
|
||||
s = SharedSettings(_env_file=None)
|
||||
assert s.credits_enabled is False
|
||||
# 旧属性名仍可用(业务代码大量引用 settings.points_enabled)
|
||||
assert s.points_enabled is False
|
||||
|
||||
def test_enable_credit_system_env(self, monkeypatch):
|
||||
from packages.config import base as base_mod
|
||||
|
||||
monkeypatch.setenv("ENABLE_CREDIT_SYSTEM", "true")
|
||||
s = base_mod.SharedSettings(_env_file=None)
|
||||
assert s.points_enabled is True
|
||||
assert s.credits_enabled is True
|
||||
|
||||
def test_legacy_points_enabled_env_alias(self, monkeypatch):
|
||||
from packages.config import base as base_mod
|
||||
|
||||
monkeypatch.setenv("ENABLE_CREDIT_SYSTEM", "false")
|
||||
monkeypatch.setenv("POINTS_ENABLED", "true")
|
||||
s = base_mod.SharedSettings(_env_file=None)
|
||||
assert s.points_enabled is True
|
||||
assert s.credits_enabled is False
|
||||
assert s.points_enabled_compat is True
|
||||
|
||||
def test_legacy_setter_back_compat(self):
|
||||
from packages.config.base import SharedSettings
|
||||
|
||||
s = SharedSettings(_env_file=None)
|
||||
s.points_enabled = True
|
||||
assert s.credits_enabled is True
|
||||
assert s.points_enabled is True
|
||||
s.points_enabled = False
|
||||
assert s.points_enabled is False
|
||||
|
||||
|
||||
# ── /points/check:关闭时恒放行、需 0 积分 ────────────────────────────────
|
||||
|
||||
|
||||
class TestCheckEndpointWhenDisabled:
|
||||
def test_check_allowed_zero_required(self):
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.get_or_create_account.return_value = {"balance": 0}
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsCheckRequest(scene_key="ai_voice", quantity=1, duration_minutes=5)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=False),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
resp = check_points(body=body, current_user=cu, db=db)
|
||||
|
||||
assert resp.allowed is True
|
||||
assert resp.required_points == 0
|
||||
assert resp.remaining_after == 0
|
||||
# 不再走免费额度判定
|
||||
svc.check_daily_free_clip.assert_not_called()
|
||||
|
||||
def test_unknown_scene_still_400_when_disabled(self):
|
||||
"""未知 scene 即使系统关闭也返回 400(参数校验先于开关)。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
check_points(body=PointsCheckRequest(scene_key="nope"), current_user=_make_cu(), db=MagicMock())
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_check_enabled_calculates_cost(self):
|
||||
"""开关开启时保持原有计费校验。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.check_daily_free_clip.return_value = False
|
||||
svc.get_or_create_account.return_value = {"balance": 100}
|
||||
body = PointsCheckRequest(scene_key="ai_title", quantity=1)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=True),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
resp = check_points(body=body, current_user=_make_cu(), db=MagicMock())
|
||||
|
||||
assert resp.required_points == 2 # 免费用户 ceil(1*1.15)=2
|
||||
|
||||
|
||||
# ── /points/deduct:关闭时 no-op,余额不变 ────────────────────────────────
|
||||
|
||||
|
||||
class TestDeductEndpointWhenDisabled:
|
||||
def test_deduct_is_noop(self):
|
||||
from app.api.routes.points import deduct_points
|
||||
from app.schemas.points import PointsDeductRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.get_or_create_account.return_value = {"balance": 7}
|
||||
body = PointsDeductRequest(scene_key="ai_voice", amount=999)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=False),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
resp = deduct_points(body=body, current_user=_make_cu(), db=MagicMock())
|
||||
|
||||
svc.deduct_points.assert_not_called()
|
||||
assert resp.success is True
|
||||
assert resp.data["balance"] == 7
|
||||
assert resp.data["transaction_id"] == ""
|
||||
|
||||
def test_deduct_enabled_works_as_before(self):
|
||||
from app.api.routes.points import deduct_points
|
||||
from app.schemas.points import PointsDeductRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.deduct_points.return_value = {"success": True, "balance": 8, "transaction_id": "tx-1"}
|
||||
body = PointsDeductRequest(scene_key="ai_title", amount=2)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=True),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
resp = deduct_points(body=body, current_user=_make_cu(), db=MagicMock())
|
||||
|
||||
svc.deduct_points.assert_called_once()
|
||||
assert resp.data["balance"] == 8
|
||||
assert resp.data["transaction_id"] == "tx-1"
|
||||
|
||||
|
||||
# ── 查询接口:系统关闭时仍全部可用 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestQueryEndpointsRemainAvailable:
|
||||
def test_balance_route_works_when_disabled(self):
|
||||
from app.api.routes.points import get_balance
|
||||
|
||||
svc = MagicMock()
|
||||
svc.get_or_create_account.return_value = {"balance": 0, "total_earned": 0, "total_spent": 0}
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=False),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
resp = get_balance(current_user=_make_cu(), db=MagicMock())
|
||||
assert resp.balance == 0
|
||||
assert resp.is_member is False
|
||||
|
||||
def test_transactions_route_works_when_disabled(self):
|
||||
from app.api.routes.points import get_transactions
|
||||
|
||||
svc = MagicMock()
|
||||
svc.get_transactions.return_value = {"items": [], "total": 0, "page": 1, "page_size": 20}
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=False),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
resp = get_transactions(current_user=_make_cu(), db=MagicMock())
|
||||
assert resp.total == 0
|
||||
|
||||
def test_daily_usage_route_works_when_disabled(self):
|
||||
from app.api.routes.points import get_daily_usage
|
||||
|
||||
svc = MagicMock()
|
||||
svc.get_daily_usage.return_value = {
|
||||
"free_clips_used": 0,
|
||||
"free_clips_limit": 2,
|
||||
"free_clips_remaining": 2,
|
||||
"reset_at": "2026-09-20T00:00:00Z",
|
||||
}
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=False),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
resp = get_daily_usage(current_user=_make_cu(), db=MagicMock())
|
||||
assert resp.free_clips_limit == 2
|
||||
|
||||
|
||||
# ── 业务路由:开关关闭时 PointsService 不实例化、不扣分 ───────────────────
|
||||
|
||||
|
||||
class TestBusinessRoutesBypassWhenDisabled:
|
||||
def test_lipsync_route_skips_points(self):
|
||||
"""lipsync 创建任务路由:settings.points_enabled=False 时不构造 PointsService。"""
|
||||
from app.api.routes import lipsync as lipsync_mod
|
||||
|
||||
assert bool(getattr(lipsync_mod.settings, "points_enabled", False)) is False
|
||||
|
||||
def test_tts_route_skips_points(self):
|
||||
from app.api.routes import tts as tts_mod
|
||||
|
||||
assert bool(getattr(tts_mod.settings, "points_enabled", False)) is False
|
||||
@@ -0,0 +1,238 @@
|
||||
"""LipsyncService GPU 路径集成测试 (#1978 异步版本).
|
||||
|
||||
#1978 性能修复:GPU 推理从同步等待改为异步。
|
||||
- _submit_audio_direct 创建 GPU 任务后立即返回,job.status="processing"
|
||||
- Celery 任务 lipsync_gpu_process_async 负责等待结果+回写
|
||||
- 本测试验证:创建任务、异步派发、音频转存等逻辑
|
||||
"""
|
||||
|
||||
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="videos/video.mp4", audio_url="audios/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
|
||||
|
||||
|
||||
def _patch_storage(public_url="https://own-bucket.oss-cn-beijing.aliyuncs.com", signed_suffix="?signed-7d"):
|
||||
"""patch get_shared_storage_service,返回自家 OSS storage mock."""
|
||||
storage = MagicMock()
|
||||
storage.public_url = public_url
|
||||
storage.get_download_url.side_effect = lambda key_or_url, expires_seconds=3600: key_or_url + signed_suffix
|
||||
return patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage)
|
||||
|
||||
|
||||
class TestGpuFallback:
|
||||
def test_switch_off_uses_mediakit(self, fake_db, fake_mediakit):
|
||||
"""开关关闭时直接走 MediaKit,不创建 GPU 任务."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=False)
|
||||
job = _make_job()
|
||||
with patch.object(svc, "_submit_to_gpu_create") 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_dispatches_async(self, fake_db, fake_mediakit):
|
||||
"""#1978 异步:GPU 任务创建成功 → job.status=processing,Celery 异步派发."""
|
||||
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-task-1")
|
||||
with (
|
||||
_patch_storage(),
|
||||
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
|
||||
patch("app.services.lipsync_service.lipsync_gpu_process_async") as m_celery,
|
||||
):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_gpu_svc.create_task.assert_called_once()
|
||||
fake_mediakit.submit_lipsync.assert_not_called()
|
||||
# 异步模式:job 立即设为 processing,Celery 任务派发
|
||||
assert job.status == "processing"
|
||||
assert job.mediakit_task_id == "gpu:gpu-task-1"
|
||||
m_celery.apply_async.assert_called_once_with(args=("job-1", "u1", "gpu-task-1"))
|
||||
|
||||
def test_gpu_celery_dispatch_failure_falls_back_sync(self, fake_db, fake_mediakit):
|
||||
"""Celery 派发失败 → 降级同步等待 GPU 结果."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
gpu_done = MagicMock(
|
||||
id="gpu-task-1",
|
||||
status="done",
|
||||
result_url="gpu-lipsync/results/gpu-task-1.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_storage() as storage_p,
|
||||
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
|
||||
patch("app.services.lipsync_service.lipsync_gpu_process_async") as m_celery,
|
||||
):
|
||||
m_celery.apply_async.side_effect = RuntimeError("Celery down")
|
||||
storage = storage_p()
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
# 降级同步等待完成
|
||||
fake_gpu_svc.wait_for_result.assert_called_once()
|
||||
assert job.status == "completed"
|
||||
assert job.output_duration == 12.5
|
||||
storage.get_download_url.assert_called_once_with(
|
||||
"gpu-lipsync/results/gpu-task-1.mp4", expires_seconds=7 * 24 * 3600
|
||||
)
|
||||
|
||||
def test_gpu_create_failure_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_storage(), 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_external_audio_persisted_to_own_oss(self, fake_db, fake_mediakit):
|
||||
"""Bug2 回归:dashscope 临时音频 URL 在创建 GPU 任务前转存自家 OSS."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
dashscope_url = "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/tmp/abc.mp3"
|
||||
job = _make_job(audio_url=dashscope_url)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-2")
|
||||
with (
|
||||
_patch_storage() as storage_p,
|
||||
patch("app.services.lipsync_service.safe_download_bytes", return_value=b"FAKE-MP3") as m_dl,
|
||||
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
|
||||
patch("app.services.lipsync_service.lipsync_gpu_process_async"),
|
||||
):
|
||||
storage = storage_p()
|
||||
storage.upload_file.return_value = "https://own-bucket.oss-cn-beijing.aliyuncs.com/lipsync-tts/u1/job-1.mp3"
|
||||
svc._submit_audio_direct(job=job)
|
||||
# 外部音频在 GPU 分支被额外下载并转存到约定 key
|
||||
gpu_dl_calls = [c for c in m_dl.call_args_list if c.kwargs.get("purpose") == "lipsync_gpu_tts_audio"]
|
||||
assert len(gpu_dl_calls) == 1
|
||||
assert gpu_dl_calls[0].args[0] == dashscope_url
|
||||
storage.upload_file.assert_called_once()
|
||||
args, kwargs = storage.upload_file.call_args
|
||||
assert args[1] == "lipsync-tts/u1/job-1.mp3"
|
||||
assert kwargs.get("content_type") == "audio/mpeg"
|
||||
# 创建 GPU 任务时用的是自家 OSS URL
|
||||
kwargs_create = fake_gpu_svc.create_task.call_args.kwargs
|
||||
assert kwargs_create["audio_url"] == "https://own-bucket.oss-cn-beijing.aliyuncs.com/lipsync-tts/u1/job-1.mp3"
|
||||
assert kwargs_create["audio_url"] != dashscope_url
|
||||
|
||||
def test_gpu_own_audio_not_repersisted(self, fake_db, fake_mediakit):
|
||||
"""Bug2:已是自家 OSS 的音频(含裸 key)不重复下载转存."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
job = _make_job(audio_url="lipsync-tts/u1/job-1.mp3")
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-3")
|
||||
with (
|
||||
_patch_storage() as storage_p,
|
||||
patch("app.services.lipsync_service.safe_download_bytes") as m_dl,
|
||||
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
|
||||
patch("app.services.lipsync_service.lipsync_gpu_process_async"),
|
||||
):
|
||||
storage = storage_p()
|
||||
svc._submit_audio_direct(job=job)
|
||||
# GPU 转存分支不应下载/上传
|
||||
gpu_dl_calls = [c for c in m_dl.call_args_list if c.kwargs.get("purpose") == "lipsync_gpu_tts_audio"]
|
||||
assert gpu_dl_calls == []
|
||||
storage.upload_file.assert_not_called()
|
||||
assert fake_gpu_svc.create_task.call_args.kwargs["audio_url"] == "lipsync-tts/u1/job-1.mp3"
|
||||
|
||||
def test_gpu_external_audio_persist_fail_uses_original_url(self, fake_db, fake_mediakit):
|
||||
"""Bug2:外部音频转存失败不阻断,用原始 URL 建任务."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
dashscope_url = "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/tmp/abc.mp3"
|
||||
job = _make_job(audio_url=dashscope_url)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-4")
|
||||
with (
|
||||
_patch_storage() as storage_p,
|
||||
patch("app.services.lipsync_service.safe_download_bytes", side_effect=RuntimeError("network blocked")),
|
||||
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
|
||||
patch("app.services.lipsync_service.lipsync_gpu_process_async"),
|
||||
):
|
||||
storage = storage_p()
|
||||
svc._submit_audio_direct(job=job)
|
||||
storage.upload_file.assert_not_called()
|
||||
assert fake_gpu_svc.create_task.call_args.kwargs["audio_url"] == dashscope_url
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = None
|
||||
assert svc.has_available_worker() is False
|
||||
@@ -102,6 +102,8 @@ def _make_service_with_mocks():
|
||||
svc = LipsyncService(db, client=client, cosyvoice_service=cosy, voice_clone_repo=MagicMock())
|
||||
# _resolve_voice_id 默认原样返回(repo.get 返回 None)
|
||||
svc._voice_clone_repo.get.return_value = None
|
||||
# 确保 GPU 路径关闭(settings 是缓存单例,其他测试可能设过 True)
|
||||
svc.settings.use_gpu_lipsync = False
|
||||
return svc, client, cosy
|
||||
|
||||
|
||||
|
||||
@@ -71,9 +71,7 @@ class TestRechargeOrderResponse:
|
||||
cu = _make_cu()
|
||||
body = PointsRechargeRequest(package_id="nonexistent")
|
||||
|
||||
with pytest.raises(HTTPException) as exc, patch(
|
||||
"app.api.routes.points._get_service", return_value=svc
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc, patch("app.api.routes.points._get_service", return_value=svc):
|
||||
create_recharge_order(body=body, current_user=cu, db=db)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
@@ -112,7 +110,10 @@ class TestCheckPointsUnknownScene:
|
||||
cu = _make_cu()
|
||||
body = PointsCheckRequest(scene_key="ai_voice", quantity=1, duration_minutes=1)
|
||||
|
||||
with patch("app.api.routes.points._get_service", return_value=svc):
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=True),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
resp = check_points(body=body, current_user=cu, db=db)
|
||||
assert resp.required_points == 2 # ceil(1 * 1.15) = 2
|
||||
assert resp.current_balance == 50
|
||||
@@ -148,22 +149,30 @@ class TestSubscriptionPlans:
|
||||
def _import_plans_fn():
|
||||
"""Import from the real file to avoid sys.modules shadowing by integration fixtures."""
|
||||
import importlib.util
|
||||
|
||||
_route_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "..", "apps", "api", "app", "api", "routes", "subscription.py",
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"_real_subscription_routes", os.path.abspath(_route_path)
|
||||
"..",
|
||||
"..",
|
||||
"apps",
|
||||
"api",
|
||||
"app",
|
||||
"api",
|
||||
"routes",
|
||||
"subscription.py",
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("_real_subscription_routes", os.path.abspath(_route_path))
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
# inject settings before exec
|
||||
import os as _os
|
||||
|
||||
_os.environ.setdefault("JWT_SECRET_KEY", "test-secret")
|
||||
_spec.loader.exec_module(_mod)
|
||||
return _mod.list_membership_plans
|
||||
|
||||
def test_plans_endpoint_returns_three_tiers(self):
|
||||
import os # noqa: F401 (used by _import_plans_fn)
|
||||
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
@@ -177,6 +186,7 @@ class TestSubscriptionPlans:
|
||||
|
||||
def test_longer_plans_cheaper_per_month(self):
|
||||
import os # noqa: F401
|
||||
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
@@ -211,9 +221,10 @@ class TestMultiplierConsistency:
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
|
||||
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite"]:
|
||||
body = PointsCheckRequest(scene_key=scene, quantity=1)
|
||||
with patch("app.api.routes.points._get_service", return_value=svc):
|
||||
resp = check_points(body=body, current_user=cu, db=db)
|
||||
expected = calculate_points_cost(scene, is_member=False, quantity=1)
|
||||
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"
|
||||
with patch("app.api.routes.points._credits_enabled", return_value=True):
|
||||
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite"]:
|
||||
body = PointsCheckRequest(scene_key=scene, quantity=1)
|
||||
with patch("app.api.routes.points._get_service", return_value=svc):
|
||||
resp = check_points(body=body, current_user=cu, db=db)
|
||||
expected = calculate_points_cost(scene, is_member=False, quantity=1)
|
||||
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"
|
||||
|
||||
Reference in New Issue
Block a user