Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0b81a5d60 | |||
| d959dd874f | |||
| dcd0c56827 | |||
| 585bab9313 | |||
| 4a449ae496 | |||
| 112f0eb277 | |||
| 2e2d1cd73e |
+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)
|
||||
|
||||
@@ -36,6 +36,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 +64,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 +217,52 @@ 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(job=job, gpu_svc=gpu_svc)
|
||||
if gpu_task is not None:
|
||||
# GPU 任务完成:直接把结果写入 job,标为 completed
|
||||
job.mediakit_task_id = "" # GPU 路径不走 MediaKit
|
||||
job.status = STATUS_COMPLETED
|
||||
job.output_video_url = gpu_task.result_url
|
||||
job.output_duration = gpu_task.result_duration or 0.0
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
"[lipsync] GPU MuseTalk 推理完成: job_id=%s gpu_task=%s duration=%.2f",
|
||||
job.id,
|
||||
gpu_task.id,
|
||||
job.output_duration,
|
||||
)
|
||||
# output_video_url 已是 _submit_to_gpu 内签好的 7 天预签名 URL
|
||||
return
|
||||
# wait_for_result 返回 None 表示超时/最终失败 → 继续走 MediaKit 兜底
|
||||
logger.warning("[lipsync] GPU 任务等待超时或失败,回退 MediaKit: job_id=%s", job.id)
|
||||
self.db.rollback() # 回滚可能的中间状态
|
||||
except Exception as exc:
|
||||
logger.exception("[lipsync] GPU 路径异常,回退 MediaKit: job_id=%s err=%s", job.id, exc)
|
||||
try:
|
||||
self.db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. 签名 URL 并提交 MediaKit(兜底路径)
|
||||
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 +291,118 @@ 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(self, *, job, gpu_svc) -> Optional[object]:
|
||||
"""创建 GPU 任务并同步等待结果。
|
||||
|
||||
成功返回终态 task 对象(status=done);超时或 GPU 最终失败返回 None,
|
||||
调用方回退 MediaKit。
|
||||
|
||||
输入处理:
|
||||
- job.video_url 为用户上传视频,已在自家 OSS(裸 key 或自家 URL),
|
||||
gpu_svc 在 poll 时签预签名 URL 给 Worker。
|
||||
- job.audio_url 可能是预合成 TTS 的第三方临时地址(如
|
||||
dashscope-result-bj.oss-cn-beijing.aliyuncs.com),Worker 家庭网络
|
||||
拉不到;创建任务前先转存自家 OSS 再传入。
|
||||
"""
|
||||
storage = get_shared_storage_service()
|
||||
# 外部音频(dashscope 临时链接等)先转存自家 OSS,避免 Worker 家庭网络拉取失败
|
||||
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 任务
|
||||
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,
|
||||
)
|
||||
# 同步等待 Worker 处理完成(轮询 DB)
|
||||
final_task = gpu_svc.wait_for_result(gpu_task.id)
|
||||
if final_task is None:
|
||||
logger.warning("[lipsync] GPU 任务等待超时,回退 MediaKit: gpu_task=%s", gpu_task.id)
|
||||
return None
|
||||
if final_task.status != "done":
|
||||
logger.warning(
|
||||
"[lipsync] GPU 任务失败: gpu_task=%s status=%s err=%s",
|
||||
gpu_task.id,
|
||||
final_task.status,
|
||||
final_task.error_msg,
|
||||
)
|
||||
return None
|
||||
# result_url 是 OSS 存储 key(gpu-lipsync/results/{task_id}.mp4,无 host,
|
||||
# _sign_media_url 对裸 key 不会签名);直接用 storage 签 7 天预签名 URL
|
||||
# 写回 job.output_video_url,保证前端拿到可直接下载播放的地址
|
||||
try:
|
||||
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 结果视频签名失败,回退原始 result_url: gpu_task=%s err=%s",
|
||||
gpu_task.id,
|
||||
exc,
|
||||
)
|
||||
return final_task
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,8 +48,33 @@ vim .env
|
||||
| `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` |
|
||||
| `MUSE_ENABLE_VIDEO_LOOP` | 驱动音频比视频长时循环视频补齐画面,`0` 关闭 | `1` |
|
||||
|
||||
### 2.2 启动服务
|
||||
### 2.2 更新部署(音轨修复,必做)
|
||||
|
||||
> ⚠️ 2026-09-20 修复严重 bug:旧版封装保留了源视频音轨,结果口型配的是原声而不是 TTS 驱动音频。RTX2060 机器必须重新拉取 `musetalk_server.py` 并重启:
|
||||
|
||||
```bash
|
||||
# 在 RTX2060 上备份旧文件并拉取新版本(按实际部署路径调整)
|
||||
cp ~/projects/MuseTalk/musetalk_server.py ~/projects/MuseTalk/musetalk_server.py.bak
|
||||
# 从仓库 raw 地址下载最新版(替换为你的仓库地址/分支)
|
||||
wget -O ~/projects/MuseTalk/musetalk_server.py \
|
||||
"https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker/musetalk_server.py"
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart musetalk-server
|
||||
sudo systemctl status musetalk-server
|
||||
curl http://127.0.0.1:7861/health
|
||||
```
|
||||
|
||||
修复后封装逻辑:
|
||||
|
||||
- 最终 mux 强制 `-map 0:v -map 1:a`:视频流只取 MuseTalk 无声画面,音轨只取 TTS 驱动音频,杜绝 ffmpeg 默认行为带入源视频音轨
|
||||
- 驱动音频不长于视频时:`-c:v copy -c:a aac -shortest`,无损秒封装
|
||||
- 驱动音频长于视频时(如 TTS 15s vs 视频 9s):`-stream_loop -1` 循环画面,RTX2060 走 `h264_nvenc` 硬件重编码(NVENC 失败自动回退 libx264),`-t` 精确卡到音频时长
|
||||
|
||||
### 2.3 启动服务
|
||||
|
||||
```bash
|
||||
# 前台运行(调试用)
|
||||
@@ -60,7 +85,7 @@ sudo systemctl start musetalk-server
|
||||
sudo systemctl enable musetalk-server
|
||||
```
|
||||
|
||||
### 2.3 验证健康检查
|
||||
### 2.4 验证健康检查
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:7861/health
|
||||
@@ -184,3 +209,9 @@ MuseTalk 健康检查通过: {...}
|
||||
新增:
|
||||
- `/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` 单任务覆盖
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
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
|
||||
MUSE_ENABLE_VIDEO_LOOP 驱动音频比视频长时是否循环视频补齐,默认 1(开启)
|
||||
|
||||
接口:
|
||||
GET /health 健康检查 + GPU 显存信息
|
||||
@@ -57,6 +59,12 @@ class Config:
|
||||
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()}")
|
||||
# 循环视频时编码器:auto 优先 h264_nvenc(RTX2060 支持),失败兜底 libx264
|
||||
video_encoder: str = _env("MUSE_VIDEO_ENCODER", "auto") or "auto"
|
||||
# 驱动音频比视频长时循环视频补齐画面
|
||||
enable_video_loop: bool = _env("MUSE_ENABLE_VIDEO_LOOP", "1") not in ("0", "false", "False", "")
|
||||
# 判定音视频时长差异的容差(秒),避免 ffprobe 微小误差触发无谓的循环/重编码
|
||||
duration_epsilon: float = 0.25
|
||||
|
||||
|
||||
# ── 全局状态 ──────────────────────────────────────────────────────────
|
||||
@@ -159,6 +167,152 @@ def _get_video_fps(video_path: Path) -> float:
|
||||
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,
|
||||
enable_video_loop: Optional[bool] = None,
|
||||
timeout: float = 300,
|
||||
) -> None:
|
||||
"""把无声画面视频与驱动音频封装为最终结果.
|
||||
|
||||
关键正确性要求:必须用 -map 0:v -map 1:a 显式指定取第一个输入(推理画面)的
|
||||
视频流和第二个输入(驱动音频 TTS)的音频流,禁止 ffmpeg 默认流选择行为
|
||||
(否则会把源视频自带音轨带进结果,口型与声音错位)。
|
||||
|
||||
时长对齐:驱动音频比视频长时(TTS 15s vs 原视频 9s 很常见),用
|
||||
-stream_loop -1 循环视频画面到音频长度(NVENC 硬件重编码),-t 卡到音频时长;
|
||||
音频不超过视频时直接 -c:v copy 无损快封装,-shortest 以较短流为准。
|
||||
"""
|
||||
video_duration = _get_media_duration(video_path)
|
||||
audio_duration = _get_media_duration(audio_path)
|
||||
|
||||
loop_enabled = Config.enable_video_loop if enable_video_loop is None else enable_video_loop
|
||||
need_loop = bool(
|
||||
loop_enabled
|
||||
and audio_duration > 0
|
||||
and video_duration > 0
|
||||
and audio_duration > video_duration + Config.duration_epsilon
|
||||
)
|
||||
|
||||
if need_loop:
|
||||
encoder = _pick_video_encoder()
|
||||
# preset 随编码器选择:h264_nvenc 用 p1-p7,libx264 用词形 preset
|
||||
preset = "p4" if encoder == "h264_nvenc" else "veryfast"
|
||||
logger.info(
|
||||
"音频(%.2fs)长于视频(%.2fs),循环视频并以 %s(%s) 重编码至音频长度",
|
||||
audio_duration,
|
||||
video_duration,
|
||||
encoder,
|
||||
preset,
|
||||
)
|
||||
|
||||
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:
|
||||
# NVENC 可能因驱动/占用失败,兜底 libx264 重试一次
|
||||
if encoder == "h264_nvenc":
|
||||
logger.warning("h264_nvenc 封装失败,回退 libx264 重试")
|
||||
_run_ffmpeg(build_cmd("libx264", "veryfast"), timeout=timeout)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# 视频不短于音频:直接复制视频流,只把音频替换为驱动音频并转 AAC
|
||||
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)
|
||||
@@ -190,11 +344,18 @@ def _run_ffmpeg(cmd: list, timeout: float = 120) -> subprocess.CompletedProcess:
|
||||
raise RuntimeError(f"ffmpeg 超时(>{timeout}s)") from exc
|
||||
|
||||
|
||||
def _run_inference(video_path: Path, audio_path: Path, output_path: Path) -> None:
|
||||
def _run_inference(
|
||||
video_path: Path,
|
||||
audio_path: Path,
|
||||
output_path: Path,
|
||||
enable_video_loop: Optional[bool] = None,
|
||||
) -> None:
|
||||
"""执行 MuseTalk 推理(可被子线程和测试独立调用).
|
||||
|
||||
实际部署时替换为 MuseTalk 真实推理逻辑。
|
||||
此处为示例实现:提取帧 → 合并音视频。
|
||||
此处为示例实现:提取帧 → 生成无声画面 → 用驱动音频封装。
|
||||
|
||||
enable_video_loop: 驱动音频长于视频时是否循环视频;None 走全局配置。
|
||||
"""
|
||||
fps = _get_video_fps(video_path)
|
||||
logger.info("视频 fps: %.2f", fps)
|
||||
@@ -218,27 +379,32 @@ def _run_inference(video_path: Path, audio_path: Path, output_path: Path) -> Non
|
||||
if not frame_files:
|
||||
raise RuntimeError("未从视频中提取到帧")
|
||||
|
||||
# TODO: 替换为 MuseTalk 实际推理逻辑
|
||||
# TODO: 替换为 MuseTalk 实际推理逻辑。
|
||||
# MuseTalk 真实产物是「无声画面视频」,音轨必须在封装阶段用驱动音频替换。
|
||||
logger.warning("使用示例推理逻辑,未实际调用 MuseTalk 模型")
|
||||
|
||||
# 示例:从源视频生成无声画面(-an 丢弃原音轨),模拟 MuseTalk 推理产物。
|
||||
# 真实部署时 silent_video_path 应替换为 MuseTalk 输出的无声视频路径。
|
||||
silent_video_path = video_path.parent / "visual_silent.mp4"
|
||||
_run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-shortest",
|
||||
str(output_path),
|
||||
"-preset",
|
||||
"veryfast",
|
||||
str(silent_video_path),
|
||||
],
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
# 统一封装:显式 -map 取推理画面 + 驱动音频;音频更长时循环视频。
|
||||
_mux_video_with_audio(silent_video_path, audio_path, output_path, enable_video_loop=enable_video_loop)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size < 1024:
|
||||
raise RuntimeError("推理产物不存在或过小")
|
||||
|
||||
@@ -286,6 +452,13 @@ def inference():
|
||||
audio_file = request.files["audio"]
|
||||
task_id = request.form.get("task_id", f"task_{int(time.time())}")
|
||||
|
||||
# 可选:本次任务是否在音频长于视频时循环视频(缺省走全局配置)
|
||||
loop_param = request.form.get("enable_video_loop")
|
||||
if loop_param is not None:
|
||||
task_enable_loop = loop_param.strip() not in ("0", "false", "False", "")
|
||||
else:
|
||||
task_enable_loop = None
|
||||
|
||||
# 文件大小检查
|
||||
err = _check_file_size(video_file, Config.video_max_mb, "视频")
|
||||
if err:
|
||||
@@ -319,7 +492,7 @@ def inference():
|
||||
|
||||
def inference_thread():
|
||||
try:
|
||||
_run_inference(video_path, audio_path, output_path)
|
||||
_run_inference(video_path, audio_path, output_path, enable_video_loop=task_enable_loop)
|
||||
except Exception as exc:
|
||||
result_container["error"] = str(exc)
|
||||
|
||||
@@ -413,6 +586,8 @@ def main():
|
||||
logger.info(" 视频大小限制: %dMB", Config.video_max_mb)
|
||||
logger.info(" 音频大小限制: %dMB", Config.audio_max_mb)
|
||||
logger.info(" 默认 fps: %.1f", Config.default_fps)
|
||||
logger.info(" 视频编码器: %s", Config.video_encoder)
|
||||
logger.info(" 音频长于视频时循环视频: %s", Config.enable_video_loop)
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 检查 GPU
|
||||
|
||||
+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,374 @@
|
||||
"""#1978 MuseTalk 服务端音轨替换 + 视频循环修复单测.
|
||||
|
||||
覆盖 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. enable_video_loop=false 时即使音频更长也不循环
|
||||
5. h264_nvenc 失败自动回退 libx264
|
||||
6. 真实 ffmpeg 端到端:源视频内置 200Hz 音轨 + 驱动音频 800Hz,结果音轨必须是 800Hz
|
||||
(过零率估计),证明音轨来自第二个输入而非源视频;音频更长时输出时长对齐音频
|
||||
"""
|
||||
|
||||
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_mux_{os.getpid()}_{id(tmp_path)}")
|
||||
mod.Config.video_encoder = "libx264"
|
||||
mod.Config.enable_video_loop = True
|
||||
return mod
|
||||
|
||||
|
||||
# ── 命令构造:非循环路径 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mux_non_loop_maps_video_and_drives_audio(server, tmp_path):
|
||||
"""音频(5s)不长于视频(10s):显式 map 0:v/1:a,视频流 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))
|
||||
# 关键修复:强制流映射,不能让 ffmpeg 默认选择源视频音轨
|
||||
assert "-map" in cmd
|
||||
assert "0:v:0" in cmd
|
||||
assert "1:a:0" in cmd
|
||||
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_non_loop_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")
|
||||
assert "-stream_loop" not in captured["cmd"]
|
||||
|
||||
|
||||
# ── 命令构造:循环路径 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mux_loop_when_audio_longer_uses_stream_loop_and_nvenc(server, tmp_path):
|
||||
"""音频(15s)长于视频(9s):-stream_loop -1 循环、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_loop_disabled_falls_back_to_copy(server, tmp_path):
|
||||
"""enable_video_loop=False:即使音频更长也不循环,走 copy+shortest."""
|
||||
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, 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", enable_video_loop=False)
|
||||
assert "-stream_loop" not in captured["cmd"]
|
||||
assert captured["cmd"][captured["cmd"].index("-c:v") + 1] == "copy"
|
||||
|
||||
|
||||
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"
|
||||
# nvenc 的 preset p4 已替换为 x264 兼容值
|
||||
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):
|
||||
"""显式配置的编码器优先,auto 时探测."""
|
||||
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"
|
||||
|
||||
|
||||
# ── 真实 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 无声画面产物
|
||||
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
|
||||
|
||||
# 驱动音频 5s 长于画面 2s → 输出应接近 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_non_loop_keeps_video_copy_path(server, tmp_path):
|
||||
"""驱动音频(1s)短于视频(2s):输出约 1s,音轨仍是驱动音频."""
|
||||
source_video, _ = _make_media(tmp_path)
|
||||
short_audio = tmp_path / "short.wav"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=800:duration=1",
|
||||
str(short_audio),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
output = tmp_path / "output_short.mp4"
|
||||
server._mux_video_with_audio(source_video, short_audio, output)
|
||||
out_duration = _probe_duration(output)
|
||||
assert abs(out_duration - 1.0) < 0.4
|
||||
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,244 @@
|
||||
"""LipsyncService GPU 路径集成测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_db():
|
||||
db = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_mediakit():
|
||||
client = MagicMock()
|
||||
client.submit_lipsync.return_value = {"task_id": "mk-task-1"}
|
||||
return client
|
||||
|
||||
|
||||
def _make_job(video_url="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,不调用 _submit_to_gpu."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=False)
|
||||
job = _make_job()
|
||||
with patch.object(svc, "_submit_to_gpu") as m_sub:
|
||||
svc._submit_audio_direct(job=job)
|
||||
m_sub.assert_not_called()
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_switch_on_no_worker_falls_back(self, fake_db, fake_mediakit):
|
||||
"""开关打开但 has_available_worker=False → 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = False
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_gpu_svc.create_task.assert_not_called()
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_gpu_success_marks_completed(self, fake_db, fake_mediakit):
|
||||
"""GPU 路径成功:job 直接 completed,不调 MediaKit;结果 key 由 storage 签 7 天 URL."""
|
||||
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),
|
||||
):
|
||||
storage = storage_p()
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_gpu_svc.create_task.assert_called_once()
|
||||
fake_mediakit.submit_lipsync.assert_not_called()
|
||||
assert job.status == "completed"
|
||||
assert job.output_duration == 12.5
|
||||
# Bug1 回归:裸 result key 必须经 storage.get_download_url 签 7 天,前端才可播放
|
||||
storage.get_download_url.assert_called_once_with(
|
||||
"gpu-lipsync/results/gpu-task-1.mp4", expires_seconds=7 * 24 * 3600
|
||||
)
|
||||
assert job.output_video_url == "gpu-lipsync/results/gpu-task-1.mp4?signed-7d"
|
||||
fake_db.commit.assert_called()
|
||||
|
||||
def test_gpu_timeout_falls_back(self, fake_db, fake_mediakit):
|
||||
"""wait_for_result 返回 None(超时)→ 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-t")
|
||||
fake_gpu_svc.wait_for_result.return_value = None
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_gpu_failed_status_falls_back(self, fake_db, fake_mediakit):
|
||||
"""GPU 终态 failed → 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-t")
|
||||
fake_gpu_svc.wait_for_result.return_value = MagicMock(status="failed", error_msg="musetalk crash")
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_gpu_exception_falls_back(self, fake_db, fake_mediakit):
|
||||
"""GPU 路径抛异常 → 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.side_effect = RuntimeError("DB down")
|
||||
with _patch_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)
|
||||
gpu_done = MagicMock(id="gpu-task-2", status="done", result_url="gpu-lipsync/results/gpu-task-2.mp4")
|
||||
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")
|
||||
fake_gpu_svc.wait_for_result.return_value = gpu_done
|
||||
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),
|
||||
):
|
||||
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 分支被额外下载(purpose 区分于前置 ffprobe 下载)并转存到约定 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,Worker 可经预签名下载
|
||||
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")
|
||||
gpu_done = MagicMock(id="gpu-task-3", status="done", result_url="gpu-lipsync/results/gpu-task-3.mp4")
|
||||
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")
|
||||
fake_gpu_svc.wait_for_result.return_value = gpu_done
|
||||
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),
|
||||
):
|
||||
storage = storage_p()
|
||||
svc._submit_audio_direct(job=job)
|
||||
# 前置 ffprobe 下载允许发生,但 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_falls_back_original_url(self, fake_db, fake_mediakit):
|
||||
"""Bug2:外部音频转存失败不阻断,用原始 URL 建任务(失败后服务端重试/回退 MediaKit)。"""
|
||||
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)
|
||||
gpu_done = MagicMock(id="gpu-task-4", status="done", result_url="gpu-lipsync/results/gpu-task-4.mp4")
|
||||
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")
|
||||
fake_gpu_svc.wait_for_result.return_value = gpu_done
|
||||
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),
|
||||
):
|
||||
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
|
||||
# 模拟SQL filter条件成立 → first() 返回非None
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = MagicMock()
|
||||
assert svc.has_available_worker() is True
|
||||
|
||||
def test_stale_worker_unavailable(self, fake_db):
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
svc = GpuLipsyncService(db=fake_db)
|
||||
# filter条件不成立(stale)→ first() 返回None
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = None
|
||||
assert svc.has_available_worker() is False
|
||||
@@ -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