Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 570a06f8c4 | |||
| 36e8f91e5e | |||
| ffb8ea6790 | |||
| cc01a5da69 | |||
| 7babea3d24 | |||
| a9f596fca3 | |||
| 9ba036abb3 | |||
| 3424e55a32 | |||
| 8c715474f4 | |||
| 93cb3e12a0 | |||
| e7bf85ca86 | |||
| 38ffa0b98b | |||
| c9876d70e4 | |||
| 8413713315 | |||
| 9d8c6260e3 | |||
| 0764a7820c | |||
| db621b4fcb | |||
| 60cacdf280 | |||
| 08de0d9946 | |||
| b0b81a5d60 | |||
| d959dd874f | |||
| dcd0c56827 | |||
| 585bab9313 | |||
| 4a449ae496 | |||
| 112f0eb277 | |||
| 2e2d1cd73e | |||
| 32473485d7 |
+7
-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) ====================
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""#2001 爆款标题样式面板升级: ai_avatar_render_jobs 新增 cover_title_config
|
||||
|
||||
Revision ID: 083_cover_title_config
|
||||
Revises: 082_atom_clip_ai_tags
|
||||
Create Date: 2026-09-20
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "083_cover_title_config"
|
||||
down_revision = "082_atom_clip_ai_tags"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ai_avatar_render_jobs",
|
||||
sa.Column("cover_title_config", sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ai_avatar_render_jobs", "cover_title_config")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""lipsync_jobs 新增 style 字段(TTS 语气风格)
|
||||
|
||||
Revision ID: 084_lipsync_jobs_style
|
||||
Revises: 083_cover_title_config
|
||||
Create Date: 2026-09-21
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "084_lipsync_jobs_style"
|
||||
down_revision = "083_cover_title_config"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"lipsync_jobs",
|
||||
sa.Column("style", sa.String(length=32), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("lipsync_jobs", "style")
|
||||
@@ -63,6 +63,7 @@ def create_render_job(
|
||||
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
|
||||
title_config=body.title_config,
|
||||
cover_config=body.cover_config,
|
||||
cover_title_config=body.cover_title_config,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except AiAvatarRenderError as exc:
|
||||
|
||||
@@ -111,6 +111,8 @@ def create_lipsync_job(
|
||||
voice_id=body.voice_id,
|
||||
script_text=body.script_text,
|
||||
speed=body.speed,
|
||||
style=body.style or "",
|
||||
volume=body.volume if body.volume is not None else 50,
|
||||
emotion=body.emotion,
|
||||
enable_video_loop=body.enable_video_loop,
|
||||
project_id=body.project_id,
|
||||
@@ -211,6 +213,8 @@ def preview_tts(
|
||||
voice_id=body.voice_id,
|
||||
script_text=body.script_text,
|
||||
speed=body.speed,
|
||||
style=body.style or "",
|
||||
volume=body.volume if body.volume is not None else 50,
|
||||
emotion=body.emotion,
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -207,6 +207,9 @@ def synthesize(
|
||||
synthesis_meta = {
|
||||
"speed": request.speed,
|
||||
"emotion": request.emotion or "",
|
||||
"style": request.style or "",
|
||||
"volume": request.volume if request.volume is not None else 50,
|
||||
"pitch": request.pitch if request.pitch is not None else 1.0,
|
||||
"language": request.language or "zh-CN",
|
||||
}
|
||||
if request.metadata_:
|
||||
@@ -654,6 +657,9 @@ def preview_tts(
|
||||
text=request.text,
|
||||
voice_id=actual_voice_id,
|
||||
speed=request.speed,
|
||||
style=request.style or "",
|
||||
volume=request.volume if request.volume is not None else 50,
|
||||
pitch=request.pitch,
|
||||
emotion=request.emotion,
|
||||
language=getattr(request, "language", "zh-CN"),
|
||||
)
|
||||
|
||||
@@ -53,9 +53,14 @@ class CreateAiAvatarRenderRequest(BaseModel):
|
||||
script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)")
|
||||
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
|
||||
title_config: dict[str, Any] = Field(
|
||||
default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL)"
|
||||
default_factory=dict,
|
||||
description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL;含 line_overrides 逐行样式)",
|
||||
)
|
||||
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
|
||||
cover_title_config: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="封面独立标题配置(#2001),结构同 title_config;为空时封面不叠标题",
|
||||
)
|
||||
project_id: str = Field("", description="项目 ID")
|
||||
|
||||
@field_validator("lipsync_job_id")
|
||||
@@ -83,6 +88,7 @@ class AiAvatarRenderJobResponse(BaseModel):
|
||||
b_roll_segments: list[dict[str, Any]]
|
||||
title_config: dict[str, Any]
|
||||
cover_config: dict[str, Any]
|
||||
cover_title_config: dict[str, Any] = Field(default_factory=dict, description="封面独立标题配置")
|
||||
status: str
|
||||
progress: int
|
||||
output_video_url: str
|
||||
|
||||
@@ -29,6 +29,7 @@ class LipsyncJobResponse(BaseModel):
|
||||
voice_id: str = ""
|
||||
script_text: str = ""
|
||||
speed: float = 1.0
|
||||
style: str = ""
|
||||
emotion: str = ""
|
||||
mediakit_task_id: str
|
||||
status: str
|
||||
@@ -67,9 +68,14 @@ class CreateLipsyncJobRequest(BaseModel):
|
||||
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)")
|
||||
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
|
||||
style: Optional[str] = Field(
|
||||
None,
|
||||
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
|
||||
)
|
||||
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
|
||||
emotion: str = Field(
|
||||
"",
|
||||
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)",
|
||||
description="[deprecated] 旧情绪参数,内部映射为 style",
|
||||
)
|
||||
|
||||
enable_video_loop: bool = Field(
|
||||
@@ -123,10 +129,15 @@ class AiAvatarTtsPreviewRequest(BaseModel):
|
||||
voice_id: str = Field(..., min_length=1, max_length=128, description="音色 ID")
|
||||
script_text: str = Field(..., min_length=1, max_length=5000, description="要合成的文案")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
|
||||
style: Optional[str] = Field(
|
||||
None,
|
||||
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
|
||||
)
|
||||
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
|
||||
emotion: str = Field(
|
||||
"neutral",
|
||||
max_length=32,
|
||||
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;默认 neutral)",
|
||||
description="[deprecated] 旧情绪参数,内部映射为 style;默认 neutral",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,9 +16,15 @@ class TTSSynthesizeRequest(BaseModel):
|
||||
output_name: str = Field("", description="输出文件名")
|
||||
language: str = Field("zh-CN", description="语言")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
|
||||
style: Optional[str] = Field(
|
||||
None,
|
||||
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
|
||||
)
|
||||
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
|
||||
pitch: Optional[float] = Field(None, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0")
|
||||
emotion: str = Field(
|
||||
"",
|
||||
description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)",
|
||||
description="[deprecated] 旧情绪参数,内部映射为 style;新接入请使用 style",
|
||||
)
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
|
||||
@@ -113,9 +119,14 @@ class TTSPreviewRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
|
||||
voice_id: str = Field(..., min_length=1, description="音色 ID")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
|
||||
emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)")
|
||||
style: Optional[str] = Field(
|
||||
None,
|
||||
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
|
||||
)
|
||||
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
|
||||
emotion: str = Field("", description="[deprecated] 旧情绪参数,内部映射为 style")
|
||||
language: str = Field("zh-CN", description="语言(zh-CN/en-US 等)")
|
||||
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
|
||||
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0")
|
||||
|
||||
|
||||
class TTSPreviewResponse(BaseModel):
|
||||
|
||||
@@ -61,6 +61,7 @@ class AiAvatarRenderService:
|
||||
b_roll_segments: list[dict[str, Any]] | None = None,
|
||||
title_config: dict[str, Any],
|
||||
cover_config: dict[str, Any],
|
||||
cover_title_config: dict[str, Any] | None = None,
|
||||
project_id: str = "",
|
||||
) -> AiAvatarRenderJob:
|
||||
"""创建渲染任务.
|
||||
@@ -112,6 +113,7 @@ class AiAvatarRenderService:
|
||||
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])],
|
||||
title_config=title_config,
|
||||
cover_config=cover_config,
|
||||
cover_title_config=cover_title_config or {},
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(job)
|
||||
|
||||
@@ -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
|
||||
@@ -110,6 +111,8 @@ class LipsyncService:
|
||||
script_text: str,
|
||||
speed: float,
|
||||
emotion: str,
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
) -> str:
|
||||
"""TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL.
|
||||
|
||||
@@ -123,7 +126,9 @@ class LipsyncService:
|
||||
text=script_text,
|
||||
voice_id=actual_voice_id,
|
||||
speed=speed,
|
||||
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
|
||||
style=style,
|
||||
volume=volume,
|
||||
emotion=emotion,
|
||||
language="zh",
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
@@ -234,27 +239,32 @@ class LipsyncService:
|
||||
|
||||
if use_gpu:
|
||||
try:
|
||||
gpu_task = self._submit_to_gpu(job=job, gpu_svc=gpu_svc)
|
||||
gpu_task = self._submit_to_gpu_create(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)
|
||||
# GPU 任务已创建,设为 processing 并异步等待结果
|
||||
job.mediakit_task_id = f"gpu:{gpu_task.id}"
|
||||
job.status = "processing"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
"[lipsync] GPU MuseTalk 推理完成: job_id=%s gpu_task=%s duration=%.2f",
|
||||
job.id,
|
||||
gpu_task.id,
|
||||
job.output_duration,
|
||||
)
|
||||
# 转存到持久 OSS 路径(GPU 结果已在 gpu-lipsync/results/ 下,直接签短链)
|
||||
# 派发 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
|
||||
# wait_for_result 返回 None 表示超时/最终失败 → 继续走 MediaKit 兜底
|
||||
logger.warning("[lipsync] GPU 任务等待超时或失败,回退 MediaKit: job_id=%s", job.id)
|
||||
self.db.rollback() # 回滚可能的中间状态
|
||||
# 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:
|
||||
@@ -293,48 +303,117 @@ class LipsyncService:
|
||||
|
||||
# ── GPU MuseTalk 路径 ────────────────────────────────────────────────
|
||||
|
||||
def _submit_to_gpu(self, *, job, gpu_svc) -> Optional[object]:
|
||||
"""创建 GPU 任务并同步等待结果。
|
||||
def _is_own_oss_url(self, url: str, storage) -> bool:
|
||||
"""判断 URL / 存储 key 是否属于自家 OSS。
|
||||
|
||||
成功返回终态 task 对象(status=done);超时或 GPU 最终失败返回 None,
|
||||
调用方回退 MediaKit。
|
||||
|
||||
注意:job.video_url / job.audio_url 可能是:
|
||||
- 自家 OSS 存储 key(storage.is_own_url 判断,gpu_svc.create_task 内部
|
||||
get_download_url 会自动签预签名 URL 给 Worker)
|
||||
- 外部公网 URL(CosyVoice 临时链接等):poll 返回时原样透传给 Worker,
|
||||
Worker 可直接 GET 下载。
|
||||
- 裸存储 key(无 scheme):自家对象
|
||||
- host 与 storage.public_url host 一致:自家对象
|
||||
- 其余 http(s) 公网链接(如 dashscope-result 临时地址):外部对象
|
||||
"""
|
||||
# 创建 GPU 任务
|
||||
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=job.audio_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",
|
||||
"[lipsync] 已创建 GPU 任务(异步): job_id=%s gpu_task=%s",
|
||||
job.id,
|
||||
gpu_task.id,
|
||||
)
|
||||
# 同步等待 Worker 处理完成(轮询 DB)
|
||||
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 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 err=%s",
|
||||
"[lipsync] GPU 同步等待失败: gpu_task=%s status=%s",
|
||||
gpu_task.id,
|
||||
final_task.status,
|
||||
final_task.error_msg,
|
||||
)
|
||||
return None
|
||||
# result_url 是 OSS 存储 key;签一个长有效期 URL 写回 job.output_video_url
|
||||
result_signed = self._sign_media_url(final_task.result_url)
|
||||
final_task.result_url = result_signed or final_task.result_url
|
||||
return final_task
|
||||
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,
|
||||
)
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -349,6 +428,8 @@ class LipsyncService:
|
||||
voice_id: str = "",
|
||||
script_text: str = "",
|
||||
speed: float = 1.0,
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
emotion: str = "",
|
||||
enable_video_loop: bool = True,
|
||||
project_id: str = "",
|
||||
@@ -397,6 +478,7 @@ class LipsyncService:
|
||||
voice_id=voice_id or "",
|
||||
script_text=script_text or "",
|
||||
speed=speed,
|
||||
style=style or "",
|
||||
emotion=emotion or "",
|
||||
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
|
||||
status="tts_processing" if is_tts_mode else "pending",
|
||||
@@ -418,6 +500,8 @@ class LipsyncService:
|
||||
voice_id,
|
||||
script_text,
|
||||
speed,
|
||||
style or "",
|
||||
volume,
|
||||
emotion or "",
|
||||
)
|
||||
)
|
||||
@@ -452,6 +536,8 @@ class LipsyncService:
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float = 1.0,
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
emotion: str = "neutral",
|
||||
) -> dict:
|
||||
"""同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算.
|
||||
@@ -570,6 +656,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)
|
||||
@@ -82,7 +82,9 @@ def tts_synthesize_and_submit(
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float,
|
||||
emotion: str,
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
emotion: str = "",
|
||||
):
|
||||
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
|
||||
|
||||
@@ -159,6 +161,8 @@ def tts_synthesize_and_submit(
|
||||
text=script_text,
|
||||
voice_id=voice_id,
|
||||
speed=speed,
|
||||
style=style,
|
||||
volume=volume,
|
||||
emotion=emotion,
|
||||
language="zh",
|
||||
)
|
||||
|
||||
@@ -7,8 +7,19 @@ export interface GenerateCoverTitleConfig {
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean | { enabled?: boolean; width?: number; color?: string }
|
||||
shadow?:
|
||||
| boolean
|
||||
| { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string }
|
||||
line_height?: number
|
||||
margin_top?: number
|
||||
max_chars_per_line?: number
|
||||
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
|
||||
line_overrides?: Array<Record<string, unknown>>
|
||||
cover_title_config?: Record<string, unknown>
|
||||
pos_x?: number
|
||||
pos_y?: number
|
||||
}
|
||||
|
||||
export interface GenerateCoverRequest {
|
||||
|
||||
@@ -81,7 +81,7 @@ export interface CreateGenerationTaskRequest {
|
||||
tts_voice_source?: "preset" | "clone"
|
||||
/** #1970:智能降重开关(默认 true) */
|
||||
dedup_enabled?: boolean
|
||||
/** 标题烧录配置 */
|
||||
/** 标题烧录配置(#2001 扩展:描边/阴影参数/行距/自动换行/背景/逐行/封面) */
|
||||
title_config?: {
|
||||
text?: string
|
||||
font?: string
|
||||
@@ -89,8 +89,34 @@ export interface CreateGenerationTaskRequest {
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean | { enabled?: boolean; width?: number; color?: string }
|
||||
shadow?:
|
||||
| boolean
|
||||
| {
|
||||
enabled?: boolean
|
||||
offset_x?: number
|
||||
offset_y?: number
|
||||
blur?: number
|
||||
color?: string
|
||||
}
|
||||
line_height?: number
|
||||
margin_top?: number
|
||||
max_chars_per_line?: number
|
||||
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
|
||||
line_overrides?: Array<{
|
||||
line_index: number
|
||||
text?: string
|
||||
size?: number
|
||||
color?: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean
|
||||
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
|
||||
}>
|
||||
cover_title_config?: Record<string, unknown>
|
||||
pos_x?: number
|
||||
pos_y?: number
|
||||
}
|
||||
/** 关联的草稿 ID(编辑流程数据链路用) */
|
||||
source_edit_plan_id?: string
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface SegmentTtsConfig {
|
||||
pitch: number
|
||||
volume: number
|
||||
subtitle_sync: boolean
|
||||
/** 配音风格预设(natural/excited/professional/sweet/news/livestream) */
|
||||
style?: string
|
||||
}
|
||||
|
||||
/** 片段裁剪配置 */
|
||||
|
||||
@@ -18,6 +18,9 @@ export type {
|
||||
TTSPreviewResponse,
|
||||
} from "./types"
|
||||
|
||||
export type { TtsStyle, TtsStyleOption } from "./styles"
|
||||
export { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, getTtsStyle } from "./styles"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
synthesizeSpeech,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* TTS 配音风格预设(情感/语气风格)
|
||||
* - key:传给后端的 style 标识,便于后端按策略合成
|
||||
* - 未传 style 时后端默认自然亲切
|
||||
*
|
||||
* 注:与原 emotion(CosyVoice 7 种基础情绪枚举)解耦;
|
||||
* style 是更高层的"说话风格预设",后端可能映射到 emotion + speed + prompt 组合。
|
||||
*/
|
||||
|
||||
export interface TtsStyleOption {
|
||||
/** 传给后端的风格标识 */
|
||||
value: string
|
||||
/** 展示名 */
|
||||
label: string
|
||||
/** emoji 图标 */
|
||||
emoji: string
|
||||
/** 给用户/后端的风格描述(prompt 风格) */
|
||||
description: string
|
||||
}
|
||||
|
||||
export const TTS_STYLE_OPTIONS: readonly TtsStyleOption[] = [
|
||||
{
|
||||
value: "natural",
|
||||
label: "自然亲切",
|
||||
emoji: "😊",
|
||||
description: "亲切自然,像朋友聊天",
|
||||
},
|
||||
{
|
||||
value: "excited",
|
||||
label: "激动兴奋",
|
||||
emoji: "🤩",
|
||||
description: "激动兴奋,语速稍快,充满活力",
|
||||
},
|
||||
{
|
||||
value: "professional",
|
||||
label: "沉稳专业",
|
||||
emoji: "🧑💼",
|
||||
description: "沉稳专业,语速适中,正式可靠",
|
||||
},
|
||||
{
|
||||
value: "sweet",
|
||||
label: "温柔甜美",
|
||||
emoji: "🌸",
|
||||
description: "温柔甜美,语速轻柔",
|
||||
},
|
||||
{
|
||||
value: "news",
|
||||
label: "新闻播报",
|
||||
emoji: "📰",
|
||||
description: "字正腔圆,严肃正式",
|
||||
},
|
||||
{
|
||||
value: "livestream",
|
||||
label: "直播带货",
|
||||
emoji: "🎤",
|
||||
description: "热情有感染力,有节奏感",
|
||||
},
|
||||
] as const
|
||||
|
||||
export type TtsStyle = (typeof TTS_STYLE_OPTIONS)[number]["value"]
|
||||
|
||||
/** 默认风格:自然亲切 */
|
||||
export const DEFAULT_TTS_STYLE: TtsStyle = "natural"
|
||||
|
||||
/** 根据 value 查找风格选项(容错:找不到回退 natural) */
|
||||
export function getTtsStyle(value: string | null | undefined): TtsStyleOption {
|
||||
return (
|
||||
(TTS_STYLE_OPTIONS as readonly TtsStyleOption[]).find((o) => o.value === value) ??
|
||||
(TTS_STYLE_OPTIONS as readonly TtsStyleOption[])[0]
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export interface TTSSynthesizeRequest {
|
||||
output_name?: string
|
||||
language?: string
|
||||
emotion?: string
|
||||
/** 配音风格预设(自然亲切/激动兴奋/沉稳专业/温柔甜美/新闻播报/直播带货),不传默认 natural */
|
||||
style?: string
|
||||
speed?: number
|
||||
voice_model?: string
|
||||
voice_clone_profile_id?: string
|
||||
@@ -106,6 +108,8 @@ export interface TTSPreviewRequest {
|
||||
pitch?: number
|
||||
language?: string
|
||||
emotion?: string // 情绪参数:neutral/happy/sad/angry/surprised/fearful/disgusted(后端 normalize_emotion() 兼容旧 natural/excited/calm/friendly 与中文标签)
|
||||
/** 配音风格预设 */
|
||||
style?: string
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
|
||||
@@ -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,361 @@
|
||||
/**
|
||||
* 标题样式相关常量(#2001)
|
||||
* - 字体列表(新增4款爆款字体)
|
||||
* - 色板(常用标题字色/描边色/背景色)
|
||||
* - 预设样式方案(10 个,含抖音爆款黄)
|
||||
*/
|
||||
import type { TitleStyleConfig } from "./types"
|
||||
|
||||
/* ── 字体选项(#2001:新增优设标题黑/阿里普惠体Bold/抖音美好体/思源黑体Heavy) ── */
|
||||
export interface FontOption {
|
||||
value: string
|
||||
label: string
|
||||
/** CSS font-family 栈 */
|
||||
family: string
|
||||
/** 爆款/常用标签 */
|
||||
tag?: "hot" | "new"
|
||||
}
|
||||
|
||||
export const FONT_OPTIONS: FontOption[] = [
|
||||
{
|
||||
value: "优设标题黑",
|
||||
label: "优设标题黑",
|
||||
family:
|
||||
'"YouShe Title Black","YouSheBiaoTiHei","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif',
|
||||
tag: "hot",
|
||||
},
|
||||
{
|
||||
value: "阿里普惠体Bold",
|
||||
label: "阿里普惠体Bold",
|
||||
family:
|
||||
'"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif',
|
||||
tag: "hot",
|
||||
},
|
||||
{
|
||||
value: "抖音美好体",
|
||||
label: "抖音美好体",
|
||||
family: '"Douyin Sans","DouyinSans","Source Han Sans SC","PingFang SC",sans-serif',
|
||||
tag: "hot",
|
||||
},
|
||||
{
|
||||
value: "思源黑体Heavy",
|
||||
label: "思源黑体Heavy",
|
||||
family:
|
||||
'"Source Han Sans SC Heavy","Noto Sans SC","Source Han Sans CN Heavy","PingFang SC",sans-serif',
|
||||
tag: "new",
|
||||
},
|
||||
{
|
||||
value: "思源黑体",
|
||||
label: "思源黑体",
|
||||
family: '"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
|
||||
},
|
||||
{
|
||||
value: "思源宋体",
|
||||
label: "思源宋体",
|
||||
family: '"Source Han Serif SC","Noto Serif SC","Songti SC","SimSun",serif',
|
||||
},
|
||||
{
|
||||
value: "苹方",
|
||||
label: "苹方",
|
||||
family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif',
|
||||
},
|
||||
{
|
||||
value: "微软雅黑",
|
||||
label: "微软雅黑",
|
||||
family: '"Microsoft YaHei","PingFang SC",sans-serif',
|
||||
},
|
||||
{
|
||||
value: "楷体",
|
||||
label: "楷体",
|
||||
family: '"KaiTi","STKaiti","DFKai-SB",serif',
|
||||
},
|
||||
]
|
||||
|
||||
/** 根据中文名取 font-family 栈(找不到回退思源黑体) */
|
||||
export function getFontFamily(font: string): string {
|
||||
const f = FONT_OPTIONS.find((x) => x.value === font)
|
||||
if (f) return f.family
|
||||
return FONT_OPTIONS[4].family // 思源黑体
|
||||
}
|
||||
|
||||
/* ── 色板 ── */
|
||||
/** 标题字色(常用爆款色) */
|
||||
export const TITLE_COLOR_PALETTE: string[] = [
|
||||
"#ffffff",
|
||||
"#000000",
|
||||
"#ffd700", // 抖音黄
|
||||
"#ff2d55", // 抖音红
|
||||
"#ff4081",
|
||||
"#00e5ff",
|
||||
"#d4a843",
|
||||
"#ffa500",
|
||||
"#52c41a",
|
||||
"#1890ff",
|
||||
"#7c3aed",
|
||||
"#ff6b35",
|
||||
]
|
||||
|
||||
/** 描边色(黑/白/灰为主) */
|
||||
export const STROKE_COLOR_PALETTE: string[] = [
|
||||
"#000000",
|
||||
"#ffffff",
|
||||
"#333333",
|
||||
"#555555",
|
||||
"#8b0000",
|
||||
"#001f3f",
|
||||
]
|
||||
|
||||
/** 背景色(带透明度) */
|
||||
export const BG_COLOR_PALETTE: string[] = [
|
||||
"rgba(0,0,0,0.5)",
|
||||
"rgba(0,0,0,0.7)",
|
||||
"rgba(0,0,0,0.3)",
|
||||
"rgba(255,215,0,0.9)",
|
||||
"rgba(255,45,85,0.85)",
|
||||
"rgba(124,58,237,0.85)",
|
||||
"rgba(24,144,255,0.85)",
|
||||
"rgba(82,196,26,0.85)",
|
||||
]
|
||||
|
||||
/* ── 预设样式方案(10 个,含抖音爆款黄) ── */
|
||||
export interface TitlePreset {
|
||||
key: string
|
||||
label: string
|
||||
emoji: string
|
||||
/** 应用时覆盖到 TitleStyleConfig 的字段(其他字段保持当前值) */
|
||||
style: Partial<TitleStyleConfig>
|
||||
}
|
||||
|
||||
const BASE: Partial<TitleStyleConfig> = {
|
||||
line_overrides: [],
|
||||
cover_title_config: null,
|
||||
}
|
||||
|
||||
export const TITLE_PRESETS: TitlePreset[] = [
|
||||
{
|
||||
key: "douyin_hot",
|
||||
label: "抖音爆款黄",
|
||||
emoji: "🔥",
|
||||
style: {
|
||||
...BASE,
|
||||
font: "优设标题黑",
|
||||
size: 80,
|
||||
color: "#ffd700",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
stroke_width: 8,
|
||||
stroke_color: "#000000",
|
||||
shadow: true,
|
||||
shadow_offset_x: 3,
|
||||
shadow_offset_y: 3,
|
||||
shadow_blur: 6,
|
||||
shadow_color: "rgba(0,0,0,0.6)",
|
||||
bg_enabled: false,
|
||||
line_height: 1.25,
|
||||
max_chars_per_line: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "classic_white",
|
||||
label: "经典白字黑描边",
|
||||
emoji: "⚪",
|
||||
style: {
|
||||
...BASE,
|
||||
font: "思源黑体Heavy",
|
||||
size: 56,
|
||||
color: "#ffffff",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
stroke_width: 5,
|
||||
stroke_color: "#000000",
|
||||
shadow: false,
|
||||
bg_enabled: false,
|
||||
line_height: 1.2,
|
||||
max_chars_per_line: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "red_bold",
|
||||
label: "醒目红字",
|
||||
emoji: "🔴",
|
||||
style: {
|
||||
...BASE,
|
||||
font: "优设标题黑",
|
||||
size: 72,
|
||||
color: "#ff2d55",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
stroke_width: 6,
|
||||
stroke_color: "#ffffff",
|
||||
shadow: true,
|
||||
shadow_offset_x: 2,
|
||||
shadow_offset_y: 2,
|
||||
shadow_blur: 5,
|
||||
shadow_color: "rgba(0,0,0,0.5)",
|
||||
bg_enabled: false,
|
||||
line_height: 1.2,
|
||||
max_chars_per_line: 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "black_gold",
|
||||
label: "黑金质感",
|
||||
emoji: "🟡",
|
||||
style: {
|
||||
...BASE,
|
||||
font: "思源宋体",
|
||||
size: 52,
|
||||
color: "#d4a843",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
shadow_offset_x: 2,
|
||||
shadow_offset_y: 2,
|
||||
shadow_blur: 8,
|
||||
shadow_color: "rgba(0,0,0,0.8)",
|
||||
bg_enabled: false,
|
||||
line_height: 1.25,
|
||||
max_chars_per_line: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "neon_blue",
|
||||
label: "霓虹发光",
|
||||
emoji: "💙",
|
||||
style: {
|
||||
...BASE,
|
||||
font: "阿里普惠体Bold",
|
||||
size: 60,
|
||||
color: "#00e5ff",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
shadow_offset_x: 0,
|
||||
shadow_offset_y: 0,
|
||||
shadow_blur: 16,
|
||||
shadow_color: "#00e5ff",
|
||||
bg_enabled: false,
|
||||
line_height: 1.2,
|
||||
max_chars_per_line: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bg_black",
|
||||
label: "黑底白字",
|
||||
emoji: "⬛",
|
||||
style: {
|
||||
...BASE,
|
||||
font: "思源黑体Heavy",
|
||||
size: 52,
|
||||
color: "#ffffff",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
bg_enabled: true,
|
||||
bg_color: "rgba(0,0,0,0.7)",
|
||||
bg_padding: 16,
|
||||
bg_radius: 8,
|
||||
line_height: 1.3,
|
||||
max_chars_per_line: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bg_yellow",
|
||||
label: "黄底黑字",
|
||||
emoji: "🟨",
|
||||
style: {
|
||||
...BASE,
|
||||
font: "抖音美好体",
|
||||
size: 56,
|
||||
color: "#000000",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
bg_enabled: true,
|
||||
bg_color: "rgba(255,215,0,0.95)",
|
||||
bg_padding: 14,
|
||||
bg_radius: 6,
|
||||
line_height: 1.2,
|
||||
max_chars_per_line: 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "sweet_pink",
|
||||
label: "温柔甜美粉",
|
||||
emoji: "🌸",
|
||||
style: {
|
||||
...BASE,
|
||||
font: "阿里普惠体Bold",
|
||||
size: 50,
|
||||
color: "#ff4081",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
stroke_width: 4,
|
||||
stroke_color: "#ffffff",
|
||||
shadow: true,
|
||||
shadow_offset_x: 2,
|
||||
shadow_offset_y: 2,
|
||||
shadow_blur: 4,
|
||||
shadow_color: "rgba(255,64,129,0.4)",
|
||||
bg_enabled: false,
|
||||
line_height: 1.3,
|
||||
max_chars_per_line: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "business_dark",
|
||||
label: "商务深色",
|
||||
emoji: "💼",
|
||||
style: {
|
||||
...BASE,
|
||||
font: "思源黑体",
|
||||
size: 44,
|
||||
color: "#ffffff",
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
shadow_offset_x: 1,
|
||||
shadow_offset_y: 1,
|
||||
shadow_blur: 3,
|
||||
shadow_color: "rgba(0,0,0,0.8)",
|
||||
bg_enabled: true,
|
||||
bg_color: "rgba(24,144,255,0.85)",
|
||||
bg_padding: 12,
|
||||
bg_radius: 4,
|
||||
line_height: 1.3,
|
||||
max_chars_per_line: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "minimal_clean",
|
||||
label: "极简无描边",
|
||||
emoji: "✨",
|
||||
style: {
|
||||
...BASE,
|
||||
font: "苹方",
|
||||
size: 48,
|
||||
color: "#ffffff",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
bg_enabled: false,
|
||||
line_height: 1.3,
|
||||
max_chars_per_line: 10,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** 根据 key 获取预设 */
|
||||
export function getTitlePreset(key: string): TitlePreset | undefined {
|
||||
return TITLE_PRESETS.find((p) => p.key === key)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 共享标题样式配置类型(#2001 爆款标题样式配置面板升级)
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 向后兼容:保留旧的 bold/stroke/shadow 布尔字段,新增细粒度字段
|
||||
* (stroke_width/stroke_color/shadow_offset_x-y-blur-color/bg_enabled-color-padding-radius/line_height/margin_top/max_chars_per_line)。
|
||||
* 2. 后端契约:字段名使用 snake_case,与 title_config dict 直接对齐。
|
||||
* 3. line_overrides 支持逐行覆盖(选中某行单独设置颜色/字号/关键词高亮/加粗/斜体)。
|
||||
* 4. cover_title_config 为封面独立标题样式,null 表示封面沿用主标题样式。
|
||||
*/
|
||||
|
||||
/** 关键词高亮配置 */
|
||||
export interface TitleKeywordHighlight {
|
||||
/** 要高亮的词 */
|
||||
word: string
|
||||
/** 高亮颜色(可选,默认主色反转) */
|
||||
color?: string
|
||||
/** 是否加粗(默认 true) */
|
||||
bold?: boolean
|
||||
/** 额外字号放大倍数(1.0=不变,1.3=放大 30%) */
|
||||
scale?: number
|
||||
}
|
||||
|
||||
/** 单行覆盖配置 */
|
||||
export interface TitleLineOverride {
|
||||
/** 行索引(0-based,按 / 或自动换行后的行序) */
|
||||
line_index: number
|
||||
/** 覆盖后的文字(可选,默认沿用原行) */
|
||||
text?: string
|
||||
/** 覆盖字号(可选) */
|
||||
size?: number
|
||||
/** 覆盖字色(可选) */
|
||||
color?: string
|
||||
/** 覆盖加粗(可选) */
|
||||
bold?: boolean
|
||||
/** 覆盖斜体(可选) */
|
||||
italic?: boolean
|
||||
/** 覆盖描边开关(可选) */
|
||||
stroke?: boolean
|
||||
/** 关键词高亮列表 */
|
||||
highlights?: TitleKeywordHighlight[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题样式配置(不含 title 文字本身,不含 auto_subtitle)。
|
||||
*
|
||||
* cover_title_config 使用 Partial<Omit<...,"cover_title_config">> 递归避免无限类型。
|
||||
*/
|
||||
export interface TitleStyleConfig {
|
||||
/* ── 基础 ── */
|
||||
font: string
|
||||
size: number
|
||||
color: string
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
position: "top" | "center" | "bottom" | "custom"
|
||||
pos_x?: number
|
||||
pos_y?: number
|
||||
|
||||
/* ── 排版(P0) ── */
|
||||
/** 行距倍数(默认 1.2) */
|
||||
line_height: number
|
||||
/** 顶部边距(position=top 时距画面顶部距离,px @720p,默认 24) */
|
||||
margin_top: number
|
||||
/** 每行最大字符数(4-20,超出自动换行;0=不自动换行,使用 / 手动分行) */
|
||||
max_chars_per_line: number
|
||||
|
||||
/* ── 描边参数化(P0) ── */
|
||||
stroke: boolean
|
||||
stroke_width: number
|
||||
stroke_color: string
|
||||
|
||||
/* ── 阴影参数化(P1) ── */
|
||||
shadow: boolean
|
||||
shadow_offset_x: number
|
||||
shadow_offset_y: number
|
||||
shadow_blur: number
|
||||
shadow_color: string
|
||||
|
||||
/* ── 背景色块(P1) ── */
|
||||
bg_enabled: boolean
|
||||
bg_color: string
|
||||
bg_padding: number
|
||||
bg_radius: number
|
||||
|
||||
/* ── 逐行独立样式(P1) ── */
|
||||
line_overrides: TitleLineOverride[]
|
||||
|
||||
/* ── 封面独立标题配置(P1):null=沿用主标题样式 ── */
|
||||
cover_title_config: null | Partial<Omit<TitleStyleConfig, "cover_title_config">>
|
||||
}
|
||||
|
||||
/** 默认样式(经典白字黑描边,保持老版本观感) */
|
||||
export const DEFAULT_TITLE_STYLE: TitleStyleConfig = {
|
||||
font: "思源黑体",
|
||||
size: 48,
|
||||
color: "#ffffff",
|
||||
bold: true,
|
||||
italic: false,
|
||||
position: "bottom",
|
||||
line_height: 1.2,
|
||||
margin_top: 24,
|
||||
max_chars_per_line: 0,
|
||||
stroke: true,
|
||||
stroke_width: 4,
|
||||
stroke_color: "#000000",
|
||||
shadow: false,
|
||||
shadow_offset_x: 2,
|
||||
shadow_offset_y: 2,
|
||||
shadow_blur: 4,
|
||||
shadow_color: "rgba(0,0,0,0.8)",
|
||||
bg_enabled: false,
|
||||
bg_color: "rgba(0,0,0,0.5)",
|
||||
bg_padding: 12,
|
||||
bg_radius: 8,
|
||||
line_overrides: [],
|
||||
cover_title_config: null,
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* TTS 配音风格选择器
|
||||
* - 6 种预设风格卡片(自然亲切 / 激动兴奋 / 沉稳专业 / 温柔甜美 / 新闻播报 / 直播带货)
|
||||
* - 卡片单选,选中高亮紫色
|
||||
* - 默认 natural
|
||||
*
|
||||
* 复用方式:
|
||||
* <TtsStyleSelector value={style} onChange={setStyle} />
|
||||
* <TtsStyleSelector value={style} onChange={setStyle} compact /> // 紧凑模式(小尺寸)
|
||||
*/
|
||||
import React from "react"
|
||||
import { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
|
||||
|
||||
export interface TtsStyleSelectorProps {
|
||||
value?: TtsStyle | string
|
||||
onChange: (style: TtsStyle) => void
|
||||
/** 紧凑模式(小卡片),适合与其他参数并排 */
|
||||
compact?: boolean
|
||||
/** 是否显示"配音风格"标签 */
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const TtsStyleSelector: React.FC<TtsStyleSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
showLabel = true,
|
||||
}) => {
|
||||
const current = value || DEFAULT_TTS_STYLE
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div>
|
||||
{showLabel && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary, #6b7280)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
配音风格
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{TTS_STYLE_OPTIONS.map((opt) => {
|
||||
const selected = current === opt.value
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value as TtsStyle)}
|
||||
title={opt.description}
|
||||
style={{
|
||||
padding: "6px 4px",
|
||||
borderRadius: 6,
|
||||
border: selected ? "2px solid #7c3aed" : "1px solid #e5e7eb",
|
||||
background: selected ? "#faf5ff" : "#fff",
|
||||
color: selected ? "#6d28d9" : "#374151",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? 600 : 400,
|
||||
textAlign: "center",
|
||||
transition: "all 0.15s",
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 3 }}>{opt.emoji}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showLabel && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary, #6b7280)",
|
||||
marginBottom: 8,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
配音风格
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{TTS_STYLE_OPTIONS.map((opt) => {
|
||||
const selected = current === opt.value
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value as TtsStyle)}
|
||||
title={opt.description}
|
||||
style={{
|
||||
padding: "10px 8px",
|
||||
borderRadius: 8,
|
||||
border: selected ? "2px solid #7c3aed" : "1px solid #e5e7eb",
|
||||
background: selected ? "#faf5ff" : "#fff",
|
||||
color: selected ? "#6d28d9" : "#111",
|
||||
cursor: "pointer",
|
||||
textAlign: "center",
|
||||
transition: "all 0.15s",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 22, lineHeight: 1 }}>{opt.emoji}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: selected ? 600 : 500 }}>{opt.label}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "#9ca3af",
|
||||
lineHeight: 1.2,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{opt.description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TtsStyleSelector
|
||||
@@ -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),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -30,11 +30,7 @@ import {
|
||||
} from "./api/aiAvatar"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { RenderJob, SentenceTiming } from "./types"
|
||||
import {
|
||||
normalizeEmotion,
|
||||
buildTitleConfigPayload,
|
||||
buildCoverConfigPayload,
|
||||
} from "./utils/contract"
|
||||
import { buildTitleConfigPayload, buildCoverConfigPayload } from "./utils/contract"
|
||||
import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas"
|
||||
|
||||
/** 面板折叠状态 */
|
||||
@@ -94,7 +90,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
state.resetTtsPreview()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion])
|
||||
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.style])
|
||||
|
||||
const _clearTtsProgressTimer = useCallback(() => {
|
||||
if (ttsProgressTimerRef.current) {
|
||||
@@ -148,7 +144,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
voice_id: state.selectedVoice!.voice_id,
|
||||
script_text: state.scriptText,
|
||||
speed: state.speed,
|
||||
emotion: normalizeEmotion(state.emotion),
|
||||
style: state.style,
|
||||
})
|
||||
_clearTtsProgressTimer()
|
||||
setTtsProgress(100)
|
||||
@@ -175,7 +171,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.style])
|
||||
|
||||
const handleRetryTts = useCallback(() => {
|
||||
handleGenerateTts()
|
||||
@@ -254,7 +250,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
script_text: state.scriptText,
|
||||
video_url: videoUrl,
|
||||
speed: state.speed,
|
||||
emotion: normalizeEmotion(state.emotion),
|
||||
style: state.style,
|
||||
}
|
||||
}
|
||||
const job = await createLipsyncJob(payload)
|
||||
@@ -298,7 +294,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
state.selectedVoice,
|
||||
state.scriptText,
|
||||
state.speed,
|
||||
state.emotion,
|
||||
|
||||
state.style,
|
||||
state.ttsPreview,
|
||||
])
|
||||
|
||||
@@ -598,8 +595,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
onVoiceSourceChange={state.setVoiceSource}
|
||||
selectedVoice={state.selectedVoice}
|
||||
onSelectVoice={state.setSelectedVoice}
|
||||
emotion={state.emotion}
|
||||
onEmotionChange={state.setEmotion}
|
||||
style={state.style}
|
||||
onStyleChange={state.setStyle}
|
||||
speed={state.speed}
|
||||
onSpeedChange={state.setSpeed}
|
||||
language={state.language}
|
||||
|
||||
@@ -41,10 +41,13 @@ export const createLipsyncJob = async (data: {
|
||||
speed?: number
|
||||
/** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgusted(TTS 直生模式用;前端经 normalizeEmotion 归一化) */
|
||||
emotion?: string
|
||||
/** 配音风格预设(natural/excited/professional/sweet/news/livestream) */
|
||||
style?: string
|
||||
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
|
||||
}
|
||||
|
||||
@@ -54,6 +57,7 @@ export const previewTts = async (data: {
|
||||
script_text: string
|
||||
speed?: number
|
||||
emotion?: string
|
||||
style?: string
|
||||
}): Promise<{
|
||||
audio_url: string
|
||||
duration: number
|
||||
|
||||
@@ -29,15 +29,7 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 字体名 → CSS font-family 映射(与 titleCanvas 字体链对齐) */
|
||||
const FONT_FAMILY_MAP: Record<string, string> = {
|
||||
思源黑体:
|
||||
"'Noto Sans CJK SC', 'Source Han Sans CN', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||||
思源宋体: "'Noto Serif SC', 'Source Han Serif SC', 'SimSun', serif",
|
||||
楷体: "KaiTi, 'STKaiti', serif",
|
||||
黑体: "'Heiti SC', 'SimHei', 'Microsoft YaHei', sans-serif",
|
||||
}
|
||||
const getFontFamily = (font: string): string => FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
|
||||
import { getFontFamily as getFontFamilyByKey } from "@/components/title/constants"
|
||||
|
||||
export function PanelLipsyncPreview({
|
||||
lipsyncJob,
|
||||
@@ -83,63 +75,105 @@ export function PanelLipsyncPreview({
|
||||
const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35
|
||||
const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale])
|
||||
|
||||
/** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致) */
|
||||
const titleOverlayStyle: React.CSSProperties | null =
|
||||
/** 标题叠加样式(新字段全支持:描边宽色/阴影参数化/背景块/行距/顶部边距/自动换行) */
|
||||
const titleOverlayData =
|
||||
titleConfig?.title && containerWidth > 0
|
||||
? (() => {
|
||||
const c = titleConfig as AiAvatarTitleConfig & {
|
||||
stroke_width?: number
|
||||
stroke_color?: string
|
||||
shadow_offset_x?: number
|
||||
shadow_offset_y?: number
|
||||
shadow_blur?: number
|
||||
shadow_color?: string
|
||||
line_height?: number
|
||||
margin_top?: number
|
||||
max_chars_per_line?: number
|
||||
bg_enabled?: boolean
|
||||
bg_color?: string
|
||||
bg_padding?: number
|
||||
bg_radius?: number
|
||||
cover_title_config?: Record<string, unknown> | null
|
||||
line_overrides?: unknown[]
|
||||
}
|
||||
const baseSize = titleConfig.size || 48
|
||||
const fontSize = ps(baseSize)
|
||||
// 描边宽度基准 ≈ size * 0.06,最小 1.5px @720p
|
||||
const strokeW = Math.max(ps(1.5), +(baseSize * 0.06 * previewScale).toFixed(2))
|
||||
// 阴影按比例缩放
|
||||
const shadowBlur = ps(4)
|
||||
const shadowOffsetY = ps(2)
|
||||
// padding / top 边距按比例(基准 8px 对应预览小窗,成片基准 16px,这里 8px 对应约 0.33 缩放)
|
||||
const padV = ps(16) * 0.5 // ≈ 8px in ~240px container
|
||||
const padH = ps(24) * 0.5
|
||||
|
||||
const strokeW = c.stroke ? ps(c.stroke_width ?? 4) : 0
|
||||
const strokeC = c.stroke_color || "#000000"
|
||||
const shBlur = ps(c.shadow_blur ?? 4)
|
||||
const shOffX = ps(c.shadow_offset_x ?? 2)
|
||||
const shOffY = ps(c.shadow_offset_y ?? 2)
|
||||
const shColor = c.shadow_color || "rgba(0,0,0,0.8)"
|
||||
const lh = c.line_height ?? 1.2
|
||||
const mTop = ps(c.margin_top ?? 24)
|
||||
const bgPad = ps(c.bg_padding ?? 12)
|
||||
const bgR = ps(c.bg_radius ?? 8)
|
||||
const maxChars = c.max_chars_per_line ?? 0
|
||||
const rawText = titleConfig.title || ""
|
||||
const lines = (() => {
|
||||
const manual = rawText
|
||||
.split(/[//]/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
if (!maxChars || maxChars <= 0) return manual
|
||||
const out: string[] = []
|
||||
manual.forEach((seg) => {
|
||||
for (let i = 0; i < seg.length; i += maxChars) out.push(seg.slice(i, i + maxChars))
|
||||
})
|
||||
return out
|
||||
})()
|
||||
const padV = ps(16) * 0.5
|
||||
const textShadow = titleConfig.shadow
|
||||
? `${shOffX}px ${shOffY}px ${shBlur}px ${shColor}`
|
||||
: undefined
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
color: titleConfig.color || "#ffffff",
|
||||
fontFamily: getFontFamily(titleConfig.font || "思源黑体"),
|
||||
fontFamily: getFontFamilyByKey(titleConfig.font || "source_sans_sc"),
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: titleConfig.bold ? 700 : 400,
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
textAlign: "center",
|
||||
width: "90%",
|
||||
lineHeight: 1.2,
|
||||
padding: `${ps(4)}px ${padH}px`,
|
||||
textShadow: titleConfig.shadow
|
||||
? `0 ${shadowOffsetY}px ${shadowBlur}px rgba(0,0,0,0.8), 0 0 ${ps(2)}px rgba(0,0,0,0.5)`
|
||||
: undefined,
|
||||
WebkitTextStroke: titleConfig.stroke ? `${strokeW}px #000` : undefined,
|
||||
boxSizing: "border-box",
|
||||
wordBreak: "break-word",
|
||||
lineHeight: lh,
|
||||
WebkitTextStroke:
|
||||
titleConfig.stroke && strokeW > 0 ? `${strokeW}px ${strokeC}` : undefined,
|
||||
paintOrder: "stroke fill",
|
||||
textShadow,
|
||||
whiteSpace: "pre-wrap",
|
||||
padding: c.bg_enabled ? `${bgPad}px ${bgPad}px` : 0,
|
||||
background: c.bg_enabled ? c.bg_color || "rgba(0,0,0,0.5)" : "transparent",
|
||||
borderRadius: c.bg_enabled ? `${bgR}px` : 0,
|
||||
boxSizing: "border-box",
|
||||
display: "inline-block",
|
||||
maxWidth: "94%",
|
||||
}
|
||||
const wrap: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: onTitlePositionChange ? "auto" : "none",
|
||||
}
|
||||
|
||||
if (
|
||||
titleConfig.position === "custom" &&
|
||||
titleConfig.pos_x != null &&
|
||||
titleConfig.pos_y != null
|
||||
) {
|
||||
style.left = `${titleConfig.pos_x}%`
|
||||
style.top = `${titleConfig.pos_y}%`
|
||||
style.transform = "translateX(-50%) translateY(-50%)"
|
||||
wrap.left = `${titleConfig.pos_x}%`
|
||||
wrap.top = `${titleConfig.pos_y}%`
|
||||
wrap.transform = "translate(-50%, -50%)"
|
||||
} else if (titleConfig.position === "top") {
|
||||
style.left = "50%"
|
||||
style.top = padV
|
||||
style.transform = "translateX(-50%)"
|
||||
wrap.top = `${padV + mTop}px`
|
||||
wrap.transform = "translateX(-50%)"
|
||||
} else if (titleConfig.position === "bottom") {
|
||||
style.left = "50%"
|
||||
style.bottom = padV
|
||||
style.transform = "translateX(-50%)"
|
||||
wrap.bottom = `${padV}px`
|
||||
wrap.transform = "translateX(-50%)"
|
||||
} else {
|
||||
style.left = "50%"
|
||||
style.top = "50%"
|
||||
style.transform = "translateX(-50%) translateY(-50%)"
|
||||
wrap.top = "50%"
|
||||
wrap.transform = "translate(-50%, -50%)"
|
||||
}
|
||||
return style
|
||||
return { style, wrap, lines }
|
||||
})()
|
||||
: null
|
||||
|
||||
@@ -252,25 +286,23 @@ export function PanelLipsyncPreview({
|
||||
{isDone && lipsyncJob?.output_video_url ? (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<video src={lipsyncJob.output_video_url} controls />
|
||||
{titleOverlayStyle && (
|
||||
{titleOverlayData && (
|
||||
<div
|
||||
ref={titleDragRef}
|
||||
style={{
|
||||
...titleOverlayStyle,
|
||||
...titleOverlayData.wrap,
|
||||
cursor: onTitlePositionChange ? "grab" : "default",
|
||||
pointerEvents: onTitlePositionChange ? "auto" : "none",
|
||||
}}
|
||||
onPointerDown={handleTitlePointerDown}
|
||||
onPointerMove={handleTitlePointerMove}
|
||||
onPointerUp={handleTitlePointerUp}
|
||||
onPointerCancel={handleTitlePointerUp}
|
||||
>
|
||||
{titleConfig!.title.split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
<div style={titleOverlayData.style}>
|
||||
{titleOverlayData.lines.map((part: string, i: number) => (
|
||||
<div key={i}>{part}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,8 @@ import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
|
||||
import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete"
|
||||
import type { TitleOption } from "@/pages/generate/components/title/TitleLibraryAutoComplete"
|
||||
import type { TitleSettings } from "@/pages/generate/types"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS, TITLE_PRESETS } from "@/pages/generate/constants"
|
||||
import { POSITION_OPTIONS } from "@/pages/generate/constants"
|
||||
import { FONT_OPTIONS, TITLE_PRESETS } from "@/components/title/constants"
|
||||
import type { AiAvatarTitleConfig } from "../types"
|
||||
// #1894: 标题数据源切换到文案库,取 script.title 作为候选
|
||||
import { getScripts } from "@/api/scripts"
|
||||
@@ -49,9 +50,60 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
|
||||
.catch(() => setTitleOptions([]))
|
||||
}, [])
|
||||
|
||||
/** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */
|
||||
const titleSettings: TitleSettings = useMemo(
|
||||
() => ({
|
||||
/** AiAvatarTitleConfig (snake_case) → TitleSettings (camelCase) */
|
||||
const titleSettings: TitleSettings = useMemo(() => {
|
||||
const c = titleConfig as AiAvatarTitleConfig & {
|
||||
stroke_width?: number
|
||||
stroke_color?: string
|
||||
shadow_offset_x?: number
|
||||
shadow_offset_y?: number
|
||||
shadow_blur?: number
|
||||
shadow_color?: string
|
||||
line_height?: number
|
||||
margin_top?: number
|
||||
max_chars_per_line?: number
|
||||
bg_enabled?: boolean
|
||||
bg_color?: string
|
||||
bg_padding?: number
|
||||
bg_radius?: number
|
||||
cover_title_config?: {
|
||||
title?: string
|
||||
font?: string
|
||||
size?: number
|
||||
font_size?: number
|
||||
color?: string
|
||||
font_color?: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
position?: string
|
||||
stroke?: { enabled: boolean; width?: number; color?: string } | boolean
|
||||
stroke_width?: number
|
||||
stroke_color?: string
|
||||
shadow?:
|
||||
| {
|
||||
enabled: boolean
|
||||
offset_x?: number
|
||||
offset_y?: number
|
||||
blur?: number
|
||||
color?: string
|
||||
}
|
||||
| boolean
|
||||
shadow_offset_x?: number
|
||||
shadow_offset_y?: number
|
||||
shadow_blur?: number
|
||||
shadow_color?: string
|
||||
background?: { enabled: boolean; color?: string; padding?: number; radius?: number }
|
||||
bg_enabled?: boolean
|
||||
bg_color?: string
|
||||
bg_padding?: number
|
||||
bg_radius?: number
|
||||
line_height?: number
|
||||
margin_top?: number
|
||||
max_chars_per_line?: number
|
||||
} | null
|
||||
line_overrides?: unknown[]
|
||||
}
|
||||
return {
|
||||
aiAutoSelect: false,
|
||||
title: titleConfig.title,
|
||||
position: titleConfig.position,
|
||||
@@ -64,24 +116,219 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
|
||||
color: titleConfig.color,
|
||||
posX: null,
|
||||
posY: null,
|
||||
}),
|
||||
[titleConfig],
|
||||
)
|
||||
lineHeight: c.line_height ?? 1.2,
|
||||
marginTop: c.margin_top ?? 24,
|
||||
maxCharsPerLine: c.max_chars_per_line ?? 0,
|
||||
strokeWidth: c.stroke_width ?? 4,
|
||||
strokeColor: c.stroke_color ?? "#000000",
|
||||
shadowOffsetX: c.shadow_offset_x ?? 2,
|
||||
shadowOffsetY: c.shadow_offset_y ?? 2,
|
||||
shadowBlur: c.shadow_blur ?? 4,
|
||||
shadowColor: c.shadow_color ?? "rgba(0,0,0,0.8)",
|
||||
bgEnabled: !!c.bg_enabled,
|
||||
bgColor: c.bg_color ?? "rgba(0,0,0,0.5)",
|
||||
bgPadding: c.bg_padding ?? 12,
|
||||
bgRadius: c.bg_radius ?? 8,
|
||||
lineOverrides: Array.isArray(c.line_overrides) ? c.line_overrides : [],
|
||||
coverTitle: (() => {
|
||||
const ct = c.cover_title_config as
|
||||
| null
|
||||
| (AiAvatarTitleConfig & {
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
stroke?: { enabled?: boolean; width?: number; color?: string } | boolean
|
||||
stroke_width?: number
|
||||
stroke_color?: string
|
||||
shadow?:
|
||||
| {
|
||||
enabled?: boolean
|
||||
offset_x?: number
|
||||
offset_y?: number
|
||||
blur?: number
|
||||
color?: string
|
||||
}
|
||||
| boolean
|
||||
shadow_offset_x?: number
|
||||
shadow_offset_y?: number
|
||||
shadow_blur?: number
|
||||
shadow_color?: string
|
||||
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
|
||||
bg_enabled?: boolean
|
||||
bg_color?: string
|
||||
bg_padding?: number
|
||||
bg_radius?: number
|
||||
})
|
||||
if (!ct) return null
|
||||
const ctStroke = ct.stroke as
|
||||
{ enabled?: boolean; width?: number; color?: string } | boolean | undefined
|
||||
const ctShadow = ct.shadow as
|
||||
| {
|
||||
enabled?: boolean
|
||||
offset_x?: number
|
||||
offset_y?: number
|
||||
blur?: number
|
||||
color?: string
|
||||
}
|
||||
| boolean
|
||||
| undefined
|
||||
const ctBg = ct.background as
|
||||
{ enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined
|
||||
return {
|
||||
title: ct.title,
|
||||
font: ct.font,
|
||||
size: ct.font_size ?? ct.size,
|
||||
color: ct.font_color ?? ct.color,
|
||||
bold: ct.bold,
|
||||
italic: ct.italic,
|
||||
position: ct.position,
|
||||
stroke:
|
||||
typeof ctStroke === "object" && ctStroke ? ctStroke.enabled !== false : !!ctStroke,
|
||||
strokeWidth:
|
||||
(typeof ctStroke === "object" && ctStroke ? ctStroke.width : undefined) ??
|
||||
ct.stroke_width ??
|
||||
4,
|
||||
strokeColor:
|
||||
(typeof ctStroke === "object" && ctStroke ? ctStroke.color : undefined) ??
|
||||
ct.stroke_color ??
|
||||
"#000000",
|
||||
shadow:
|
||||
typeof ctShadow === "object" && ctShadow ? ctShadow.enabled !== false : !!ctShadow,
|
||||
shadowOffsetX:
|
||||
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_x : undefined) ??
|
||||
ct.shadow_offset_x ??
|
||||
2,
|
||||
shadowOffsetY:
|
||||
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_y : undefined) ??
|
||||
ct.shadow_offset_y ??
|
||||
2,
|
||||
shadowBlur:
|
||||
(typeof ctShadow === "object" && ctShadow ? ctShadow.blur : undefined) ??
|
||||
ct.shadow_blur ??
|
||||
4,
|
||||
shadowColor:
|
||||
(typeof ctShadow === "object" && ctShadow ? ctShadow.color : undefined) ??
|
||||
ct.shadow_color ??
|
||||
"rgba(0,0,0,0.8)",
|
||||
bgEnabled: ctBg?.enabled ?? !!ct.bg_enabled,
|
||||
bgColor: ctBg?.color ?? ct.bg_color ?? "rgba(0,0,0,0.5)",
|
||||
bgPadding: ctBg?.padding ?? ct.bg_padding ?? 12,
|
||||
bgRadius: ctBg?.radius ?? ct.bg_radius ?? 8,
|
||||
}
|
||||
})(),
|
||||
}
|
||||
}, [titleConfig])
|
||||
|
||||
/** 应用预设:与智能剪辑一致,只覆盖 color/bold/italic/stroke/shadow,不改变字号 */
|
||||
/** 应用预设:覆盖新细粒度字段(颜色/描边/阴影/字号/字体等) */
|
||||
const handleApplyPreset = (presetKey: string) => {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
setActivePreset(presetKey)
|
||||
const st = preset.style || {}
|
||||
onUpdate({
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
font: st.font,
|
||||
size: st.size,
|
||||
color: st.color,
|
||||
bold: st.bold,
|
||||
italic: st.italic,
|
||||
stroke: st.stroke,
|
||||
stroke_width: st.stroke_width,
|
||||
stroke_color: st.stroke_color,
|
||||
shadow: st.shadow,
|
||||
shadow_offset_x: st.shadow_offset_x,
|
||||
shadow_offset_y: st.shadow_offset_y,
|
||||
shadow_blur: st.shadow_blur,
|
||||
shadow_color: st.shadow_color,
|
||||
bg_enabled: st.bg_enabled,
|
||||
bg_color: st.bg_color,
|
||||
bg_padding: st.bg_padding,
|
||||
bg_radius: st.bg_radius,
|
||||
line_overrides: [],
|
||||
cover_title_config: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** 字段 patch 透传:TitleStylePanel 的 onUpdateStyle(camelCase → snake_case) */
|
||||
const handleUpdateStyle = (patch: Partial<TitleSettings>) => {
|
||||
const snake: Record<string, unknown> = {}
|
||||
const map: Record<string, string> = {
|
||||
lineHeight: "line_height",
|
||||
marginTop: "margin_top",
|
||||
maxCharsPerLine: "max_chars_per_line",
|
||||
strokeWidth: "stroke_width",
|
||||
strokeColor: "stroke_color",
|
||||
shadowOffsetX: "shadow_offset_x",
|
||||
shadowOffsetY: "shadow_offset_y",
|
||||
shadowBlur: "shadow_blur",
|
||||
shadowColor: "shadow_color",
|
||||
bgEnabled: "bg_enabled",
|
||||
bgColor: "bg_color",
|
||||
bgPadding: "bg_padding",
|
||||
bgRadius: "bg_radius",
|
||||
lineOverrides: "line_overrides",
|
||||
coverTitle: "cover_title_config",
|
||||
}
|
||||
Object.entries(patch).forEach(([k, v]) => {
|
||||
if (k === "coverTitle" && v && typeof v === "object") {
|
||||
const ct = v as {
|
||||
title?: string
|
||||
font?: string
|
||||
size?: number
|
||||
color?: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
position?: string
|
||||
stroke?: boolean
|
||||
strokeWidth?: number
|
||||
strokeColor?: string
|
||||
shadow?: boolean
|
||||
shadowOffsetX?: number
|
||||
shadowOffsetY?: number
|
||||
shadowBlur?: number
|
||||
shadowColor?: string
|
||||
bgEnabled?: boolean
|
||||
bgColor?: string
|
||||
bgPadding?: number
|
||||
bgRadius?: number
|
||||
lineHeight?: number
|
||||
marginTop?: number
|
||||
maxCharsPerLine?: number
|
||||
}
|
||||
snake.cover_title_config = {
|
||||
title: ct.title,
|
||||
font: ct.font,
|
||||
font_size: ct.size,
|
||||
font_color: ct.color,
|
||||
bold: ct.bold,
|
||||
italic: ct.italic,
|
||||
position: ct.position,
|
||||
stroke: ct.stroke
|
||||
? { enabled: true, width: ct.strokeWidth ?? 4, color: ct.strokeColor ?? "#000" }
|
||||
: { enabled: false },
|
||||
shadow: ct.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
offset_x: ct.shadowOffsetX ?? 2,
|
||||
offset_y: ct.shadowOffsetY ?? 2,
|
||||
blur: ct.shadowBlur ?? 4,
|
||||
color: ct.shadowColor ?? "rgba(0,0,0,0.8)",
|
||||
}
|
||||
: { enabled: false },
|
||||
background: ct.bgEnabled
|
||||
? { enabled: true, color: ct.bgColor, padding: ct.bgPadding, radius: ct.bgRadius }
|
||||
: { enabled: false },
|
||||
line_height: ct.lineHeight,
|
||||
margin_top: ct.marginTop,
|
||||
max_chars_per_line: ct.maxCharsPerLine,
|
||||
}
|
||||
} else if (map[k]) {
|
||||
snake[map[k]] = v
|
||||
} else {
|
||||
snake[k] = v
|
||||
}
|
||||
})
|
||||
onUpdate(snake)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-title-config">
|
||||
{/* 主标题输入 — TextArea 多行 + 标题库选择 */}
|
||||
@@ -125,8 +372,13 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
|
||||
onToggleStroke={() => onUpdate({ stroke: !titleConfig.stroke })}
|
||||
onToggleShadow={() => onUpdate({ shadow: !titleConfig.shadow })}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
onUpdateStyle={handleUpdateStyle}
|
||||
showCoverToggle
|
||||
previewWidth={280}
|
||||
activePreset={activePreset}
|
||||
titlePresets={TITLE_PRESETS}
|
||||
titlePresets={
|
||||
TITLE_PRESETS as unknown as React.ComponentProps<typeof TitleStylePanel>["titlePresets"]
|
||||
}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/**
|
||||
* AI数字人 — 配音库面板(面板3)
|
||||
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
|
||||
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、风格/语速/语言参数
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import { fetchVoices } from "@/api/voices/voices"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { normalizeEmotion } from "../utils/contract"
|
||||
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
|
||||
import type { TtsStyle } from "@/api/tts/styles"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
VOICE_EMOTION_OPTIONS,
|
||||
PRESET_VOICE_LANGUAGE_OPTIONS,
|
||||
CLONE_VOICE_LANGUAGE_OPTIONS,
|
||||
} from "../types"
|
||||
@@ -22,8 +21,8 @@ interface PanelVoiceSelectorProps {
|
||||
onVoiceSourceChange: (source: VoiceSource) => void
|
||||
selectedVoice: UnifiedVoiceItem | null
|
||||
onSelectVoice: (voice: UnifiedVoiceItem) => void
|
||||
emotion: VoiceEmotion
|
||||
onEmotionChange: (e: VoiceEmotion) => void
|
||||
style: TtsStyle
|
||||
onStyleChange: (s: TtsStyle) => void
|
||||
speed: number
|
||||
onSpeedChange: (s: number) => void
|
||||
language: VoiceLanguage
|
||||
@@ -35,8 +34,8 @@ export function PanelVoiceSelector({
|
||||
onVoiceSourceChange,
|
||||
selectedVoice,
|
||||
onSelectVoice,
|
||||
emotion,
|
||||
onEmotionChange,
|
||||
style,
|
||||
onStyleChange,
|
||||
speed,
|
||||
onSpeedChange,
|
||||
language,
|
||||
@@ -139,31 +138,30 @@ export function PanelVoiceSelector({
|
||||
/* 克隆音色:preview_url/audio_url 通常为空,需走 POST /tts/preview
|
||||
* 现合成示例文案再播放,对齐配音库 useAudioPlayer 行为 */
|
||||
if (voice.type === "clone") {
|
||||
const cached = previewCacheRef.current.get(voice.voice_clone_profile_id || voice.id)
|
||||
const cacheKey = `${voice.voice_clone_profile_id || voice.id}::${style}`
|
||||
const cached = previewCacheRef.current.get(cacheKey)
|
||||
if (cached) {
|
||||
playAudioUrl(voice.id, cached)
|
||||
return
|
||||
}
|
||||
const targetId = voice.voice_clone_profile_id || voice.id
|
||||
// DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因
|
||||
setPreviewingId(voice.id)
|
||||
try {
|
||||
const res = await previewTts({
|
||||
text: VOICE_PREVIEW_TEXT,
|
||||
voice_id: targetId,
|
||||
speed: speed, // 透传用户选择的语速(#1822)
|
||||
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
|
||||
style,
|
||||
})
|
||||
if (!res.audio_url) {
|
||||
setPreviewingId(null)
|
||||
message.error("合成试听失败:未返回音频")
|
||||
return
|
||||
}
|
||||
previewCacheRef.current.set(targetId, res.audio_url)
|
||||
previewCacheRef.current.set(cacheKey, res.audio_url)
|
||||
playAudioUrl(voice.id, res.audio_url)
|
||||
} catch (err) {
|
||||
setPreviewingId(null)
|
||||
// DEBUG: 打印详细错误信息
|
||||
console.error("[AI数字人-克隆试听] previewTts 失败:", {
|
||||
status: (err as { response?: { status?: number } })?.response?.status,
|
||||
data: (err as { response?: { data?: unknown } })?.response?.data,
|
||||
@@ -272,23 +270,6 @@ export function PanelVoiceSelector({
|
||||
{/* 配音参数 */}
|
||||
<div className="aa-voice-params">
|
||||
<div className="aa-voice-params__row">
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-emotion">
|
||||
情绪
|
||||
</label>
|
||||
<select
|
||||
id="aa-voice-emotion"
|
||||
className="aa-select"
|
||||
value={emotion}
|
||||
onChange={(e) => onEmotionChange(e.target.value as VoiceEmotion)}
|
||||
>
|
||||
{VOICE_EMOTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-language">
|
||||
语言
|
||||
@@ -324,6 +305,9 @@ export function PanelVoiceSelector({
|
||||
onChange={(e) => handleSpeedChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="aa-voice-params__field">
|
||||
<TtsStyleSelector value={style} onChange={onStyleChange} compact />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { AssetItem } from "@/api/assets"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
type Script,
|
||||
type LipsyncJob,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
DEFAULT_TITLE_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "../types"
|
||||
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
|
||||
|
||||
const DEFAULT_TTS_PREVIEW: TtsPreviewResult = {
|
||||
audioUrl: null,
|
||||
@@ -34,7 +34,7 @@ export function useAiAvatar() {
|
||||
/* ── 面板2:配音库 ── */
|
||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
|
||||
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
|
||||
const [emotion, setEmotion] = useState<VoiceEmotion>("neutral")
|
||||
const [style, setStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
|
||||
const [speed, setSpeed] = useState(1.0)
|
||||
const [language, setLanguage] = useState<VoiceLanguage>("zh")
|
||||
|
||||
@@ -113,8 +113,8 @@ export function useAiAvatar() {
|
||||
setVoiceSource,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
emotion,
|
||||
setEmotion,
|
||||
style,
|
||||
setStyle,
|
||||
speed,
|
||||
setSpeed,
|
||||
language,
|
||||
|
||||
@@ -100,7 +100,7 @@ export interface BRollSegment {
|
||||
pip_scale: number
|
||||
}
|
||||
|
||||
/* ── 标题配置 ── */
|
||||
/* ── 标题配置(#2001 升级:细粒度描边/阴影/背景/排版/逐行/封面独立标题) ── */
|
||||
export interface AiAvatarTitleConfig {
|
||||
title: string
|
||||
position: string
|
||||
@@ -115,6 +115,42 @@ export interface AiAvatarTitleConfig {
|
||||
/** 自定义位置坐标(position=custom 时生效,百分比 0-100) */
|
||||
pos_x?: number
|
||||
pos_y?: number
|
||||
|
||||
/* ── 排版 ── */
|
||||
line_height: number
|
||||
margin_top: number
|
||||
max_chars_per_line: number
|
||||
|
||||
/* ── 描边参数化 ── */
|
||||
stroke_width: number
|
||||
stroke_color: string
|
||||
|
||||
/* ── 阴影参数化 ── */
|
||||
shadow_offset_x: number
|
||||
shadow_offset_y: number
|
||||
shadow_blur: number
|
||||
shadow_color: string
|
||||
|
||||
/* ── 背景色块 ── */
|
||||
bg_enabled: boolean
|
||||
bg_color: string
|
||||
bg_padding: number
|
||||
bg_radius: number
|
||||
|
||||
/* ── 逐行覆盖 ── */
|
||||
line_overrides: Array<{
|
||||
line_index: number
|
||||
text?: string
|
||||
size?: number
|
||||
color?: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean
|
||||
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
|
||||
}>
|
||||
|
||||
/* ── 封面独立标题(null=沿用主标题) ── */
|
||||
cover_title_config: null | Partial<AiAvatarTitleConfig>
|
||||
}
|
||||
|
||||
/* ── 封面配置 ── */
|
||||
@@ -149,12 +185,27 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
|
||||
size: 48,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
auto_subtitle: true,
|
||||
pos_x: undefined,
|
||||
pos_y: undefined,
|
||||
line_height: 1.2,
|
||||
margin_top: 24,
|
||||
max_chars_per_line: 0,
|
||||
stroke_width: 4,
|
||||
stroke_color: "#000000",
|
||||
shadow_offset_x: 2,
|
||||
shadow_offset_y: 2,
|
||||
shadow_blur: 4,
|
||||
shadow_color: "rgba(0,0,0,0.8)",
|
||||
bg_enabled: false,
|
||||
bg_color: "rgba(0,0,0,0.5)",
|
||||
bg_padding: 12,
|
||||
bg_radius: 8,
|
||||
line_overrides: [],
|
||||
cover_title_config: null,
|
||||
}
|
||||
|
||||
export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
|
||||
|
||||
@@ -67,6 +67,37 @@ export function buildTitleConfigPayload(
|
||||
const text = (cfg.title || "").trim()
|
||||
if (!text) return {}
|
||||
const position = cfg.position || "bottom"
|
||||
const anyCfg = cfg as AiAvatarTitleConfig & {
|
||||
stroke_width?: number
|
||||
stroke_color?: string
|
||||
shadow_offset_x?: number
|
||||
shadow_offset_y?: number
|
||||
shadow_blur?: number
|
||||
shadow_color?: string
|
||||
line_height?: number
|
||||
margin_top?: number
|
||||
max_chars_per_line?: number
|
||||
bg_enabled?: boolean
|
||||
bg_color?: string
|
||||
bg_padding?: number
|
||||
bg_radius?: number
|
||||
line_overrides?: unknown[]
|
||||
cover_title_config?: Record<string, unknown> | null
|
||||
}
|
||||
const strokeWidth = anyCfg.stroke_width != null ? anyCfg.stroke_width : 4
|
||||
const strokeColor = anyCfg.stroke_color || "#000000"
|
||||
const shadowOffsetX = anyCfg.shadow_offset_x != null ? anyCfg.shadow_offset_x : 2
|
||||
const shadowOffsetY = anyCfg.shadow_offset_y != null ? anyCfg.shadow_offset_y : 2
|
||||
const shadowBlur = anyCfg.shadow_blur != null ? anyCfg.shadow_blur : 4
|
||||
const shadowColor = anyCfg.shadow_color || "rgba(0,0,0,0.8)"
|
||||
const lineHeight = anyCfg.line_height != null ? anyCfg.line_height : 1.2
|
||||
const marginTop = anyCfg.margin_top != null ? anyCfg.margin_top : 24
|
||||
const maxCharsPerLine = anyCfg.max_chars_per_line ?? 0
|
||||
const bgEnabled = !!anyCfg.bg_enabled
|
||||
const bgColor = anyCfg.bg_color || "rgba(0,0,0,0.5)"
|
||||
const bgPadding = anyCfg.bg_padding != null ? anyCfg.bg_padding : 12
|
||||
const bgRadius = anyCfg.bg_radius != null ? anyCfg.bg_radius : 8
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
text,
|
||||
enabled: true,
|
||||
@@ -75,16 +106,68 @@ export function buildTitleConfigPayload(
|
||||
font_color: cfg.color || "#ffffff",
|
||||
position,
|
||||
bold: !!cfg.bold,
|
||||
stroke: cfg.stroke ? { enabled: true, width: 2, color: "#000000" } : { enabled: false },
|
||||
shadow: cfg.shadow
|
||||
? { enabled: true, color: "#000000", offset_x: 2, offset_y: 2 }
|
||||
italic: !!cfg.italic,
|
||||
stroke: cfg.stroke
|
||||
? { enabled: true, width: strokeWidth, color: strokeColor }
|
||||
: { enabled: false },
|
||||
shadow: cfg.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
color: shadowColor,
|
||||
offset_x: shadowOffsetX,
|
||||
offset_y: shadowOffsetY,
|
||||
blur: shadowBlur,
|
||||
}
|
||||
: { enabled: false },
|
||||
line_height: lineHeight,
|
||||
margin_top: marginTop,
|
||||
max_chars_per_line: maxCharsPerLine,
|
||||
background: bgEnabled
|
||||
? { enabled: true, color: bgColor, padding: bgPadding, radius: bgRadius }
|
||||
: { enabled: false },
|
||||
line_overrides: Array.isArray(anyCfg.line_overrides) ? anyCfg.line_overrides : [],
|
||||
}
|
||||
// 自定义坐标(custom 位置)
|
||||
if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") {
|
||||
payload.pos_x = cfg.pos_x
|
||||
payload.pos_y = cfg.pos_y
|
||||
}
|
||||
// 封面独立标题配置
|
||||
if (anyCfg.cover_title_config) {
|
||||
const ctc = anyCfg.cover_title_config
|
||||
payload.cover_title_config = {
|
||||
title: ctc.title,
|
||||
font: ctc.font,
|
||||
font_size: ctc.size,
|
||||
font_color: ctc.color,
|
||||
position: ctc.position,
|
||||
bold: ctc.bold,
|
||||
italic: ctc.italic,
|
||||
stroke: ctc.stroke
|
||||
? { enabled: true, width: ctc.stroke_width ?? 4, color: ctc.stroke_color ?? "#000000" }
|
||||
: { enabled: false },
|
||||
shadow: ctc.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
color: ctc.shadow_color ?? shadowColor,
|
||||
offset_x: ctc.shadow_offset_x ?? 2,
|
||||
offset_y: ctc.shadow_offset_y ?? 2,
|
||||
blur: ctc.shadow_blur ?? 4,
|
||||
}
|
||||
: { enabled: false },
|
||||
line_height: ctc.line_height ?? lineHeight,
|
||||
margin_top: ctc.margin_top ?? marginTop,
|
||||
max_chars_per_line: ctc.max_chars_per_line ?? maxCharsPerLine,
|
||||
background: ctc.bg_enabled
|
||||
? {
|
||||
enabled: true,
|
||||
color: ctc.bg_color ?? bgColor,
|
||||
padding: ctc.bg_padding ?? bgPadding,
|
||||
radius: ctc.bg_radius ?? bgRadius,
|
||||
}
|
||||
: { enabled: false },
|
||||
}
|
||||
}
|
||||
// 前端 Canvas 渲染好的 PNG dataURL(所见即所得,后端优先 overlay 此图片图层)
|
||||
if (titleImageDataUrl) {
|
||||
payload.title_image_dataurl = titleImageDataUrl
|
||||
|
||||
@@ -9,37 +9,78 @@
|
||||
* 按 videoWidth / 720 得到 scale,所有长度类参数乘以 scale,
|
||||
* 保证 1080p / 4K 成片里标题视觉大小与预览一致。
|
||||
*/
|
||||
import { getFontFamily } from "@/components/title/constants"
|
||||
import type { AiAvatarTitleConfig } from "../types"
|
||||
|
||||
export interface RenderTitlePngOptions {
|
||||
/** 标题配置 */
|
||||
titleConfig: AiAvatarTitleConfig
|
||||
/** 视频宽度(像素),默认 720 */
|
||||
videoWidth?: number
|
||||
/** 视频高度(像素),默认 1280 */
|
||||
videoHeight?: number
|
||||
useCoverTitle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 将标题渲染为透明背景 PNG 的 dataURL(data:image/png;base64,...)
|
||||
* Canvas 尺寸与视频一致,保证叠加时 1:1 像素对齐。
|
||||
*
|
||||
* 标题为空时返回 null。
|
||||
*/
|
||||
export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null {
|
||||
const { titleConfig, videoWidth = 720, videoHeight = 1280 } = opts
|
||||
if (!titleConfig) return null
|
||||
const rawTitle = (titleConfig.title || "").trim()
|
||||
if (!rawTitle) return null
|
||||
|
||||
// 按 / 或 / 分割为多行
|
||||
const lines = rawTitle
|
||||
function autoWrapLines(rawTitle: string, maxCharsPerLine: number): string[] {
|
||||
const manual = rawTitle
|
||||
.split(/[//]/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
if (!maxCharsPerLine || maxCharsPerLine <= 0) return manual
|
||||
const out: string[] = []
|
||||
manual.forEach((seg) => {
|
||||
for (let i = 0; i < seg.length; i += maxCharsPerLine) {
|
||||
out.push(seg.slice(i, i + maxCharsPerLine))
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null {
|
||||
const { titleConfig, videoWidth = 720, videoHeight = 1280, useCoverTitle } = opts
|
||||
if (!titleConfig) return null
|
||||
|
||||
type TitleCfgExt = AiAvatarTitleConfig & {
|
||||
stroke_width?: number
|
||||
stroke_color?: string
|
||||
shadow_offset_x?: number
|
||||
shadow_offset_y?: number
|
||||
shadow_blur?: number
|
||||
shadow_color?: string
|
||||
line_height?: number
|
||||
margin_top?: number
|
||||
max_chars_per_line?: number
|
||||
bg_enabled?: boolean
|
||||
bg_color?: string
|
||||
bg_padding?: number
|
||||
bg_radius?: number
|
||||
line_overrides?: Array<{
|
||||
line_index: number
|
||||
text?: string
|
||||
size?: number
|
||||
color?: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean
|
||||
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
|
||||
}>
|
||||
cover_title_config?: Partial<AiAvatarTitleConfig> | null
|
||||
pos_x?: number
|
||||
pos_y?: number
|
||||
}
|
||||
const cfg: TitleCfgExt =
|
||||
useCoverTitle && titleConfig.cover_title_config
|
||||
? ({
|
||||
...(titleConfig as TitleCfgExt),
|
||||
...(titleConfig.cover_title_config as object),
|
||||
} as TitleCfgExt)
|
||||
: (titleConfig as TitleCfgExt)
|
||||
|
||||
const rawTitle = (cfg.title || "").trim()
|
||||
if (!rawTitle) return null
|
||||
|
||||
const maxCharsPerLine = cfg.max_chars_per_line ?? 0
|
||||
const lines = autoWrapLines(rawTitle, maxCharsPerLine)
|
||||
if (lines.length === 0) return null
|
||||
|
||||
// 分辨率缩放系数:基准 720p,所有长度类参数乘以 scale
|
||||
const scale = videoWidth / 720
|
||||
const r = (v: number) => Math.round(v * scale)
|
||||
|
||||
@@ -49,86 +90,183 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return null
|
||||
|
||||
const baseSize = Math.max(12, Math.round(titleConfig.size || 48))
|
||||
const baseSize = Math.max(12, Math.round(cfg.size || 48))
|
||||
const size = r(baseSize)
|
||||
const bold = !!titleConfig.bold
|
||||
const italic = !!titleConfig.italic
|
||||
const color = titleConfig.color || "#ffffff"
|
||||
const stroke = !!titleConfig.stroke
|
||||
const shadow = !!titleConfig.shadow
|
||||
const bold = !!cfg.bold
|
||||
const italic = !!cfg.italic
|
||||
const color = cfg.color || "#ffffff"
|
||||
const stroke = !!cfg.stroke
|
||||
const shadow = !!cfg.shadow
|
||||
|
||||
// 字体族 fallback 链:优先中文字体
|
||||
const fontFamily =
|
||||
'"Noto Sans CJK SC","Source Han Sans CN","PingFang SC","Microsoft YaHei",sans-serif'
|
||||
const fontParts: string[] = []
|
||||
if (italic) fontParts.push("italic")
|
||||
if (bold) fontParts.push("bold")
|
||||
fontParts.push(`${size}px`, fontFamily)
|
||||
ctx.font = fontParts.join(" ")
|
||||
const strokeWidthBase = cfg.stroke_width != null ? cfg.stroke_width : 4
|
||||
const strokeColor = cfg.stroke_color || "#000000"
|
||||
const shadowOffsetXBase = cfg.shadow_offset_x != null ? cfg.shadow_offset_x : 2
|
||||
const shadowOffsetYBase = cfg.shadow_offset_y != null ? cfg.shadow_offset_y : 2
|
||||
const shadowBlurBase = cfg.shadow_blur != null ? cfg.shadow_blur : 4
|
||||
const shadowColor = cfg.shadow_color || "rgba(0,0,0,0.8)"
|
||||
const lineHeightScale = cfg.line_height != null ? cfg.line_height : 1.2
|
||||
const marginTopBase = cfg.margin_top != null ? cfg.margin_top : 24
|
||||
const bgEnabled = !!cfg.bg_enabled
|
||||
const bgColor = cfg.bg_color || "rgba(0,0,0,0.5)"
|
||||
const bgPaddingBase = cfg.bg_padding != null ? cfg.bg_padding : 12
|
||||
const bgRadiusBase = cfg.bg_radius != null ? cfg.bg_radius : 8
|
||||
|
||||
const fontKey = cfg.font || "思源黑体"
|
||||
const fontFamily = getFontFamily(fontKey)
|
||||
const setFont = (sz: number, bd: boolean, it: boolean) => {
|
||||
const parts: string[] = []
|
||||
if (it) parts.push("italic")
|
||||
if (bd) parts.push("bold")
|
||||
parts.push(`${sz}px`, fontFamily)
|
||||
ctx.font = parts.join(" ")
|
||||
}
|
||||
setFont(size, bold, italic)
|
||||
ctx.fillStyle = color
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
|
||||
// 阴影(shadow=true 时开启)——按 scale 缩放
|
||||
if (shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.8)"
|
||||
ctx.shadowBlur = r(4)
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = r(2)
|
||||
}
|
||||
const lineGap = size * lineHeightScale
|
||||
const totalTextH = lines.length * lineGap - (lineGap - size)
|
||||
let maxLineW = 0
|
||||
lines.forEach((l: string) => {
|
||||
const m = ctx.measureText(l).width
|
||||
if (m > maxLineW) maxLineW = m
|
||||
})
|
||||
|
||||
// 位置计算:与 PanelLipsyncPreview 的 CSS 对齐(按 scale 缩放 PAD)
|
||||
const PAD = r(16)
|
||||
let centerX = videoWidth / 2
|
||||
const position = titleConfig.position || "bottom"
|
||||
const lineGap = size * 1.2
|
||||
const totalTextH = lines.length * lineGap - (lineGap - size) // 所有行的总高度
|
||||
// 文本块顶部 y(textBaseline=middle 时首行基线)
|
||||
const position = cfg.position || "bottom"
|
||||
let firstLineY: number
|
||||
if (
|
||||
position === "custom" &&
|
||||
typeof titleConfig.pos_x === "number" &&
|
||||
typeof titleConfig.pos_y === "number"
|
||||
) {
|
||||
centerX = (Math.max(0, Math.min(100, titleConfig.pos_x)) / 100) * videoWidth
|
||||
const centerY = (Math.max(0, Math.min(100, titleConfig.pos_y)) / 100) * videoHeight
|
||||
if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") {
|
||||
centerX = (Math.max(0, Math.min(100, cfg.pos_x)) / 100) * videoWidth
|
||||
const centerY = (Math.max(0, Math.min(100, cfg.pos_y)) / 100) * videoHeight
|
||||
firstLineY = centerY - totalTextH / 2 + size / 2
|
||||
} else if (position === "top") {
|
||||
// 顶部:y = size/2 + PAD
|
||||
firstLineY = size / 2 + PAD
|
||||
firstLineY = size / 2 + PAD + r(marginTopBase)
|
||||
} else if (position === "center") {
|
||||
firstLineY = videoHeight / 2 - totalTextH / 2 + size / 2
|
||||
} else {
|
||||
// bottom(默认)
|
||||
firstLineY = videoHeight - totalTextH - PAD + size / 2
|
||||
}
|
||||
|
||||
// 描边参数:描边 lineWidth 按 scale 缩放(基准 size * 0.06,最小 2px @720p)
|
||||
const doStroke = stroke
|
||||
const strokeWidth = Math.max(r(2), Math.round(size * 0.06))
|
||||
// 逐行绘制
|
||||
lines.forEach((line, idx) => {
|
||||
if (shadow) {
|
||||
ctx.shadowColor = shadowColor
|
||||
ctx.shadowBlur = r(shadowBlurBase)
|
||||
ctx.shadowOffsetX = r(shadowOffsetXBase)
|
||||
ctx.shadowOffsetY = r(shadowOffsetYBase)
|
||||
} else {
|
||||
ctx.shadowColor = "rgba(0,0,0,0)"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
|
||||
const bgPad = r(bgPaddingBase)
|
||||
const bgR = r(bgRadiusBase)
|
||||
const bgW = maxLineW + bgPad * 2
|
||||
const bgH = totalTextH + bgPad * 2
|
||||
const bgX = centerX - bgW / 2
|
||||
const bgY = firstLineY - size / 2 - bgPad
|
||||
|
||||
if (bgEnabled) {
|
||||
ctx.save()
|
||||
ctx.shadowColor = "rgba(0,0,0,0)"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
ctx.fillStyle = bgColor
|
||||
if (
|
||||
bgR > 0 &&
|
||||
(
|
||||
ctx as CanvasRenderingContext2D & {
|
||||
roundRect?: (x: number, y: number, w: number, h: number, r: number) => void
|
||||
}
|
||||
).roundRect
|
||||
) {
|
||||
;(
|
||||
ctx as CanvasRenderingContext2D & {
|
||||
roundRect?: (x: number, y: number, w: number, h: number, r: number) => void
|
||||
}
|
||||
).roundRect(bgX, bgY, bgW, bgH, bgR)
|
||||
ctx.fill()
|
||||
} else {
|
||||
ctx.fillRect(bgX, bgY, bgW, bgH)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const sw = stroke ? Math.max(r(1), r(strokeWidthBase)) : 0
|
||||
const lineOverrides = cfg.line_overrides || []
|
||||
lines.forEach((line: string, idx: number) => {
|
||||
const y = firstLineY + idx * lineGap
|
||||
if (doStroke) {
|
||||
const prevShadowColor = ctx.shadowColor
|
||||
const prevShadowBlur = ctx.shadowBlur
|
||||
// 描边不要带阴影(避免黑色描边发虚)
|
||||
const override = lineOverrides.find((lo) => lo.line_index === idx)
|
||||
const lineSize = override?.size ? r(Math.max(12, Math.round(override.size))) : size
|
||||
const lineColor = override?.color || color
|
||||
const lineBold = override?.bold != null ? !!override.bold : bold
|
||||
const lineItalic = override?.italic != null ? !!override.italic : italic
|
||||
const lineStroke = override?.stroke != null ? !!override.stroke : stroke
|
||||
|
||||
setFont(lineSize, lineBold, lineItalic)
|
||||
ctx.fillStyle = lineColor
|
||||
|
||||
if (shadow) {
|
||||
ctx.shadowColor = shadowColor
|
||||
ctx.shadowBlur = r(shadowBlurBase)
|
||||
ctx.shadowOffsetX = r(shadowOffsetXBase)
|
||||
ctx.shadowOffsetY = r(shadowOffsetYBase)
|
||||
} else {
|
||||
ctx.shadowColor = "rgba(0,0,0,0)"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.lineWidth = strokeWidth
|
||||
ctx.strokeStyle = "#000000"
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
|
||||
const lineSw = override?.size
|
||||
? Math.max(r(1), Math.round(lineSize * (strokeWidthBase / baseSize)))
|
||||
: sw
|
||||
|
||||
if (lineStroke && lineSw > 0) {
|
||||
ctx.save()
|
||||
ctx.shadowColor = "rgba(0,0,0,0)"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
ctx.lineWidth = lineSw
|
||||
ctx.strokeStyle = strokeColor
|
||||
ctx.lineJoin = "round"
|
||||
ctx.strokeText(line, centerX, y)
|
||||
// 恢复阴影
|
||||
if (shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.8)"
|
||||
ctx.shadowBlur = r(4)
|
||||
} else {
|
||||
ctx.shadowColor = prevShadowColor
|
||||
ctx.shadowBlur = prevShadowBlur
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.fillText(line, centerX, y)
|
||||
|
||||
if (override?.highlights?.length) {
|
||||
const fullW = ctx.measureText(line).width
|
||||
const charW = line.length > 0 ? fullW / line.length : lineSize
|
||||
override.highlights.forEach((hl) => {
|
||||
if (!hl.word) return
|
||||
const pos = line.indexOf(hl.word)
|
||||
if (pos < 0) return
|
||||
const hlX = centerX - fullW / 2 + pos * charW + (charW * hl.word.length) / 2
|
||||
const hlColor = hl.color || "#ffd700"
|
||||
const hlScale = hl.scale || 1
|
||||
const hlSize = lineSize * hlScale
|
||||
const hlBold = hl.bold != null ? !!hl.bold : true
|
||||
ctx.save()
|
||||
ctx.shadowColor = "rgba(0,0,0,0)"
|
||||
ctx.shadowBlur = 0
|
||||
setFont(hlSize, hlBold, lineItalic)
|
||||
ctx.fillStyle = hlColor
|
||||
if (lineStroke && lineSw > 0) {
|
||||
ctx.lineWidth = Math.max(r(1), Math.round(hlSize * (strokeWidthBase / baseSize)))
|
||||
ctx.strokeStyle = strokeColor
|
||||
ctx.lineJoin = "round"
|
||||
ctx.strokeText(hl.word, hlX, y)
|
||||
}
|
||||
ctx.fillText(hl.word, hlX, y)
|
||||
ctx.restore()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -138,9 +276,6 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频真实分辨率(HTMLVideoElement + loadedmetadata,超时 3 秒兜底 720×1280)。
|
||||
*/
|
||||
export function getVideoResolution(
|
||||
videoUrl: string,
|
||||
timeoutMs = 3000,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -76,6 +77,8 @@ const GeneratePage: React.FC = () => {
|
||||
setTtsVoiceId,
|
||||
ttsVoiceSource,
|
||||
setTtsVoiceSource,
|
||||
ttsStyle,
|
||||
setTtsStyle,
|
||||
ttsVoiceAssetId,
|
||||
setTtsVoiceAssetId,
|
||||
dedupEnabled,
|
||||
@@ -328,6 +331,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedScript,
|
||||
ttsVoiceId,
|
||||
ttsVoiceSource,
|
||||
ttsStyle,
|
||||
ttsVoiceAssetId,
|
||||
dedupEnabled,
|
||||
style,
|
||||
@@ -409,9 +413,15 @@ const GeneratePage: React.FC = () => {
|
||||
)
|
||||
|
||||
const handleTtsSynthesized = useCallback(
|
||||
(payload: { voiceAssetId: string; ttsVoiceId: string; ttsVoiceSource: "preset" | "clone" }) => {
|
||||
(payload: {
|
||||
voiceAssetId: string
|
||||
ttsVoiceId: string
|
||||
ttsVoiceSource: "preset" | "clone"
|
||||
ttsStyle?: string
|
||||
}) => {
|
||||
setTtsVoiceId(payload.ttsVoiceId)
|
||||
setTtsVoiceSource(payload.ttsVoiceSource)
|
||||
if (payload.ttsStyle) setTtsStyle(payload.ttsStyle)
|
||||
setTtsVoiceAssetId(payload.voiceAssetId)
|
||||
if (payload.ttsVoiceSource === "clone") {
|
||||
setSelectedClonedVoice(payload.ttsVoiceId)
|
||||
@@ -427,6 +437,7 @@ const GeneratePage: React.FC = () => {
|
||||
[
|
||||
setTtsVoiceId,
|
||||
setTtsVoiceSource,
|
||||
setTtsStyle,
|
||||
setTtsVoiceAssetId,
|
||||
setSelectedVoice,
|
||||
setSelectedClonedVoice,
|
||||
@@ -437,19 +448,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 +534,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
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
@@ -627,6 +640,7 @@ const GeneratePage: React.FC = () => {
|
||||
onToggleStroke={styleUpdaters.toggleStroke}
|
||||
onToggleShadow={styleUpdaters.toggleShadow}
|
||||
onApplyPreset={styleUpdaters.applyPreset}
|
||||
onUpdateStyle={styleUpdaters.updateStyle}
|
||||
activePreset={styleUpdaters.activePreset}
|
||||
titlePresets={styleUpdaters.titlePresets}
|
||||
bgm={bgm}
|
||||
@@ -788,6 +802,8 @@ const GeneratePage: React.FC = () => {
|
||||
open={ttsModalOpen}
|
||||
scriptText={selectedScript?.content ?? ""}
|
||||
scriptTitle={selectedScript?.title ?? ""}
|
||||
style={ttsStyle}
|
||||
onStyleChange={setTtsStyle}
|
||||
onCancel={() => setTtsModalOpen(false)}
|
||||
onSynthesized={handleTtsSynthesized}
|
||||
/>
|
||||
|
||||
@@ -51,8 +51,14 @@ export interface GenerateStepContentProps {
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
titlePresets: Array<{
|
||||
key: string
|
||||
label: string
|
||||
emoji?: string
|
||||
style: Record<string, unknown>
|
||||
}>
|
||||
/* ── 封面 ── */
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
@@ -119,6 +125,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
onUpdateStyle,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
coverSettings,
|
||||
@@ -191,6 +198,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateStyle={onUpdateStyle}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
previewCount={previewCount}
|
||||
|
||||
@@ -12,7 +12,8 @@ import React, { useMemo, useState } from "react"
|
||||
import { Input, message } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import { POSITION_OPTIONS } from "../constants"
|
||||
import { FONT_OPTIONS } from "@/components/title/constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
|
||||
@@ -33,8 +34,15 @@ interface Step4TitleSettingsProps {
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
titlePresets: Array<{
|
||||
key: string
|
||||
label: string
|
||||
emoji?: string
|
||||
style?: Record<string, unknown>
|
||||
previewStyle?: React.CSSProperties
|
||||
}>
|
||||
/* ── 批量生成(#1677)── */
|
||||
/** 生成数量 */
|
||||
previewCount?: number
|
||||
@@ -84,6 +92,7 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
onUpdateStyle,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
previewCount = 1,
|
||||
@@ -285,6 +294,8 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateStyle={onUpdateStyle}
|
||||
showCoverToggle
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { VOICE_GENDER_ICON } from "../constants"
|
||||
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
|
||||
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
|
||||
|
||||
interface TtsVoiceModalProps {
|
||||
open: boolean
|
||||
@@ -31,7 +33,11 @@ interface TtsVoiceModalProps {
|
||||
voiceAssetId: string
|
||||
ttsVoiceId: string
|
||||
ttsVoiceSource: "preset" | "clone"
|
||||
ttsStyle: TtsStyle
|
||||
}) => void
|
||||
/** 当前风格 */
|
||||
style?: TtsStyle
|
||||
onStyleChange?: (s: TtsStyle) => void
|
||||
}
|
||||
|
||||
type TtsSynthStatus = "idle" | "synthesizing" | "saving" | "done" | "error"
|
||||
@@ -42,7 +48,15 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
|
||||
scriptTitle,
|
||||
onCancel,
|
||||
onSynthesized,
|
||||
style: externalStyle,
|
||||
onStyleChange,
|
||||
}) => {
|
||||
const [internalStyle, setInternalStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
|
||||
const currentStyle: TtsStyle = externalStyle ?? internalStyle
|
||||
const handleStyleChange = (s: TtsStyle) => {
|
||||
setInternalStyle(s)
|
||||
onStyleChange?.(s)
|
||||
}
|
||||
const [activeTab, setActiveTab] = useState<"preset" | "clone">("preset")
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("")
|
||||
const [status, setStatus] = useState<TtsSynthStatus>("idle")
|
||||
@@ -77,6 +91,7 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
|
||||
setStatus("idle")
|
||||
setError(null)
|
||||
setActiveTab("preset")
|
||||
setInternalStyle(externalStyle ?? DEFAULT_TTS_STYLE)
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
@@ -91,6 +106,7 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const handlePreview = useCallback(
|
||||
@@ -143,6 +159,7 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
|
||||
text: textToSynth,
|
||||
speed: 1.0,
|
||||
language: "zh-CN",
|
||||
style: currentStyle,
|
||||
}
|
||||
if (isClone) {
|
||||
payload.voice_clone_profile_id = selectedVoiceId
|
||||
@@ -187,13 +204,14 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
|
||||
voiceAssetId: jobId,
|
||||
ttsVoiceId: selectedVoiceId,
|
||||
ttsVoiceSource: isClone ? "clone" : "preset",
|
||||
ttsStyle: currentStyle,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
setStatus("error")
|
||||
const msg = err instanceof Error ? err.message : "合成失败,请稍后重试"
|
||||
setError(msg)
|
||||
}
|
||||
}, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized])
|
||||
}, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized, currentStyle])
|
||||
|
||||
const renderVoiceCard = (v: {
|
||||
id: string
|
||||
@@ -393,6 +411,10 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
|
||||
{textToSynth.length} 字
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<TtsStyleSelector value={currentStyle} onChange={handleStyleChange} compact />
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(k) => {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 标题迷你 Canvas 预览(#2001)
|
||||
*
|
||||
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
|
||||
* - 预设卡片缩略图
|
||||
* - 样式面板顶部的实时预览
|
||||
*
|
||||
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
|
||||
* - 固定分辨率(width × 宽高比约 2:1)
|
||||
* - 不调用 ffmpeg,只做视觉预览
|
||||
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
|
||||
*/
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { getFontFamily } from "../../constants"
|
||||
|
||||
interface Props {
|
||||
settings: TitleSettings
|
||||
width?: number
|
||||
sampleText?: string
|
||||
/** 背景(预览用,默认深色渐变模拟视频底) */
|
||||
background?: string
|
||||
/** 高度(可选,默认 width/2) */
|
||||
height?: number
|
||||
}
|
||||
|
||||
/** 按 maxCharsPerLine 自动换行 */
|
||||
function wrapLines(text: string, maxChars: number): string[] {
|
||||
const manual = text
|
||||
.split(/[//\n]/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
if (!maxChars || maxChars <= 0) return manual
|
||||
const out: string[] = []
|
||||
for (const line of manual) {
|
||||
if (line.length <= maxChars) {
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
let cur = ""
|
||||
for (const ch of line) {
|
||||
cur += ch
|
||||
if (cur.length >= maxChars) {
|
||||
out.push(cur)
|
||||
cur = ""
|
||||
}
|
||||
}
|
||||
if (cur) out.push(cur)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const TitleMiniPreview: React.FC<Props> = ({
|
||||
settings,
|
||||
width = 200,
|
||||
sampleText,
|
||||
background = "linear-gradient(135deg,#1f2937,#111827)",
|
||||
height,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const h = height ?? Math.round(width / 1.8)
|
||||
const text = (sampleText || settings.title || "预览标题").trim() || "预览标题"
|
||||
|
||||
useEffect(() => {
|
||||
const cvs = canvasRef.current
|
||||
if (!cvs) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
cvs.width = width * dpr
|
||||
cvs.height = h * dpr
|
||||
cvs.style.width = `${width}px`
|
||||
cvs.style.height = `${h}px`
|
||||
const ctx = cvs.getContext("2d")
|
||||
if (!ctx) return
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, width, h)
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = "#111827"
|
||||
ctx.fillRect(0, 0, width, h)
|
||||
|
||||
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半)
|
||||
const scale = width / 360
|
||||
const r = (v: number) => Math.round(v * scale)
|
||||
|
||||
// 字体
|
||||
const size = r(settings.size)
|
||||
const ff = getFontFamily(settings.font)
|
||||
const parts: string[] = []
|
||||
if (settings.italic) parts.push("italic")
|
||||
if (settings.bold) parts.push("bold")
|
||||
parts.push(`${size}px`, ff)
|
||||
ctx.font = parts.join(" ")
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillStyle = settings.color
|
||||
ctx.lineJoin = "round"
|
||||
|
||||
// 阴影
|
||||
const shadowEnabled = !!settings.shadow
|
||||
const prevShadow = {
|
||||
c: ctx.shadowColor,
|
||||
b: ctx.shadowBlur,
|
||||
ox: ctx.shadowOffsetX,
|
||||
oy: ctx.shadowOffsetY,
|
||||
}
|
||||
if (shadowEnabled) {
|
||||
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
|
||||
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
|
||||
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
|
||||
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
|
||||
}
|
||||
|
||||
// 换行
|
||||
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
|
||||
const lineH = size * (settings.lineHeight ?? 1.2)
|
||||
const totalH = lines.length * lineH
|
||||
let startY: number
|
||||
if (settings.position === "top") {
|
||||
startY = size / 2 + r(settings.marginTop ?? 24)
|
||||
} else if (settings.position === "center") {
|
||||
startY = h / 2 - totalH / 2 + size / 2
|
||||
} else {
|
||||
// bottom
|
||||
startY = h - totalH - r(16) + size / 2
|
||||
}
|
||||
let centerX = width / 2
|
||||
if (settings.position === "custom" && settings.posX != null) {
|
||||
centerX = (settings.posX / 100) * width
|
||||
}
|
||||
|
||||
// 背景块
|
||||
if (settings.bgEnabled) {
|
||||
const pad = r(settings.bgPadding ?? 12)
|
||||
const rad = r(settings.bgRadius ?? 8)
|
||||
let maxLineW = 0
|
||||
for (const l of lines) {
|
||||
const m = ctx.measureText(l)
|
||||
if (m.width > maxLineW) maxLineW = m.width
|
||||
}
|
||||
const bw = maxLineW + pad * 2
|
||||
const bh = totalH + pad * 2
|
||||
const bx = centerX - bw / 2
|
||||
const by = startY - size / 2 - pad + (size - lineH) / 2
|
||||
ctx.shadowColor = "rgba(0,0,0,0)"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
|
||||
roundRect(ctx, bx, by, bw, bh, rad)
|
||||
ctx.fill()
|
||||
// 恢复阴影
|
||||
if (shadowEnabled) {
|
||||
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
|
||||
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
|
||||
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
|
||||
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 描边(先画,再画填充)
|
||||
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
|
||||
lines.forEach((line, i) => {
|
||||
const y = startY + i * lineH
|
||||
if (strokeEnabled) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0)"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.lineWidth = r(settings.strokeWidth ?? 4)
|
||||
ctx.strokeStyle = settings.strokeColor ?? "#000000"
|
||||
ctx.strokeText(line, centerX, y)
|
||||
// 恢复阴影
|
||||
if (shadowEnabled) {
|
||||
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
|
||||
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
|
||||
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
|
||||
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
|
||||
}
|
||||
}
|
||||
ctx.fillText(line, centerX, y)
|
||||
})
|
||||
|
||||
// 恢复
|
||||
ctx.shadowColor = prevShadow.c
|
||||
ctx.shadowBlur = prevShadow.b
|
||||
ctx.shadowOffsetX = prevShadow.ox
|
||||
ctx.shadowOffsetY = prevShadow.oy
|
||||
}, [settings, width, h, text])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
background,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function roundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
) {
|
||||
const rr = Math.min(r, w / 2, h / 2)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + rr, y)
|
||||
ctx.lineTo(x + w - rr, y)
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
|
||||
ctx.lineTo(x + w, y + h - rr)
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
|
||||
ctx.lineTo(x + rr, y + h)
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
|
||||
ctx.lineTo(x, y + rr)
|
||||
ctx.quadraticCurveTo(x, y, x + rr, y)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
export default TitleMiniPreview
|
||||
@@ -190,3 +190,255 @@
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
#2001 爆款标题样式面板升级 — 新增样式(ts- 前缀)
|
||||
============================================================ */
|
||||
|
||||
.ts-panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 预览 */
|
||||
.ts-preview-wrap {
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 表单字段 */
|
||||
.ts-form-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ts-form-field label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
.ts-field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ts-field-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color, #7c3aed);
|
||||
}
|
||||
.ts-row-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.ts-half {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ts-select {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary, #fff);
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
outline: 0;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
.ts-select:focus {
|
||||
border-color: var(--primary-color, #7c3aed);
|
||||
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
.ts-input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.ts-slider {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
.ts-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #7c3aed;
|
||||
cursor: pointer;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.ts-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #7c3aed;
|
||||
cursor: pointer;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
/* 样式按钮 B/I/S/☁ */
|
||||
.ts-style-btns {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.ts-style-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: 0.15s;
|
||||
color: #374151;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ts-style-btn:hover {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
.ts-style-btn.active {
|
||||
background: #faf5ff;
|
||||
color: #6d28d9;
|
||||
border-color: #7c3aed;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 色板 */
|
||||
.ts-color-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-color-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px #e5e7eb;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.ts-color-swatch:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.ts-color-swatch.active {
|
||||
box-shadow: 0 0 0 2px #7c3aed;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.ts-color-custom {
|
||||
background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.ts-color-native {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 预设网格 10个 - 5列 */
|
||||
.ts-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.ts-preset-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.ts-preset-card:hover {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
.ts-preset-card.active {
|
||||
border-color: #7c3aed;
|
||||
background: #faf5ff;
|
||||
box-shadow: 0 0 0 1px #7c3aed;
|
||||
}
|
||||
.ts-preset-preview {
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #0f172a;
|
||||
}
|
||||
.ts-preset-preview canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.ts-preset-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
color: #4b5563;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 2px 2px;
|
||||
}
|
||||
.ts-preset-emoji {
|
||||
font-size: 11px;
|
||||
}
|
||||
.ts-preset-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ts-toggle-row label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ts-toggle-row input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #7c3aed;
|
||||
}
|
||||
|
||||
/* Tabs 紧凑样式 */
|
||||
.xx-title-style-section .ant-tabs-nav {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.xx-title-style-section .ant-tabs-tab {
|
||||
font-size: 12px !important;
|
||||
padding: 6px 8px !important;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
/**
|
||||
* 标题样式设置区
|
||||
* 位置/字体/字号/样式按钮/预设
|
||||
* 标题样式设置面板(#2001 升级)
|
||||
*
|
||||
* P0:描边宽度滑块 / 描边颜色选择器 / 每行最大字符数 / 行距+顶部边距 /
|
||||
* 4款爆款字体 / 抖音爆款黄预设
|
||||
* P1:阴影参数化 / 背景色块 / Canvas 实时迷你预览 /
|
||||
* 封面独立标题配置入口
|
||||
*
|
||||
* 向后兼容:旧的 onToggleBold/Italic/Stroke/Shadow/onUpdatePosition/onUpdateFont/
|
||||
* onUpdateSize/onApplyPreset props 全部保留;新增字段通过 onUpdateStyle 统一回写。
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useState } from "react"
|
||||
import { Tabs } from "antd"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import TitlePresetsGrid from "./TitlePresetsGrid"
|
||||
// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件,
|
||||
// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css
|
||||
import {
|
||||
FONT_OPTIONS as NEW_FONT_OPTIONS,
|
||||
TITLE_PRESETS,
|
||||
TITLE_COLOR_PALETTE,
|
||||
STROKE_COLOR_PALETTE,
|
||||
BG_COLOR_PALETTE,
|
||||
} from "@/components/title/constants"
|
||||
|
||||
import TitleMiniPreview from "./TitleMiniPreview"
|
||||
import "./TitleStylePanel.css"
|
||||
|
||||
interface PositionOption {
|
||||
@@ -14,14 +28,17 @@ interface PositionOption {
|
||||
label: string
|
||||
}
|
||||
|
||||
interface TitlePresetItem {
|
||||
interface LegacyPreset {
|
||||
key: string
|
||||
label: string
|
||||
previewStyle: React.CSSProperties
|
||||
emoji?: string
|
||||
style?: Record<string, unknown>
|
||||
previewStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
interface TitleStylePanelProps {
|
||||
settings: TitleSettings
|
||||
/* 旧 props(兼容) */
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
@@ -31,9 +48,130 @@ interface TitleStylePanelProps {
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: TitlePresetItem[]
|
||||
titlePresets: LegacyPreset[]
|
||||
POSITION_OPTIONS: PositionOption[]
|
||||
FONT_OPTIONS: string[]
|
||||
FONT_OPTIONS?: Array<{ value: string; label: string; family?: string; tag?: string }>
|
||||
/* 新增:统一字段更新 */
|
||||
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
|
||||
/* 是否显示封面独立标题切换 */
|
||||
showCoverToggle?: boolean
|
||||
/** 画布预览宽度(默认 200) */
|
||||
previewWidth?: number
|
||||
}
|
||||
|
||||
/* ── 通用 Slider + Label 行 ── */
|
||||
const SliderRow: React.FC<{
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
unit?: string
|
||||
onChange: (v: number) => void
|
||||
}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => (
|
||||
<div className="ts-form-field">
|
||||
<div className="ts-field-label-row">
|
||||
<label>{label}</label>
|
||||
<span className="ts-field-value">
|
||||
{value}
|
||||
{unit}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="ts-slider"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ── 色板 + 自定义颜色选择 ── */
|
||||
const ColorPicker: React.FC<{
|
||||
label?: string
|
||||
value: string
|
||||
palette: string[]
|
||||
onChange: (c: string) => void
|
||||
}> = ({ label, value, palette, onChange }) => {
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
return (
|
||||
<div className="ts-form-field">
|
||||
{label && <label>{label}</label>}
|
||||
<div className="ts-color-row">
|
||||
{palette.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`ts-color-swatch${value.toLowerCase() === c.toLowerCase() ? " active" : ""}`}
|
||||
style={{ background: c }}
|
||||
onClick={() => onChange(c)}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="ts-color-swatch ts-color-custom"
|
||||
onClick={() => setCustomOpen((v) => !v)}
|
||||
title="自定义颜色"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
type="color"
|
||||
className="ts-color-native"
|
||||
value={value.startsWith("rgba") ? "#000000" : value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{
|
||||
opacity: customOpen ? 1 : 0,
|
||||
position: customOpen ? "static" : "absolute",
|
||||
pointerEvents: customOpen ? "auto" : "none",
|
||||
width: 0,
|
||||
height: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
|
||||
当前:<code style={{ fontSize: 11 }}>{value}</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 预设网格(含爆款黄,10 个 + 迷你 Canvas 缩略) ── */
|
||||
const PresetGrid: React.FC<{
|
||||
activePreset: string | null
|
||||
onApply: (key: string) => void
|
||||
settings: TitleSettings
|
||||
}> = ({ activePreset, onApply, settings }) => {
|
||||
return (
|
||||
<div className="ts-presets-grid">
|
||||
{TITLE_PRESETS.map((p) => {
|
||||
const isActive = activePreset === p.key
|
||||
// 合并当前 style 与 preset.style 用于预览(仅预览时覆盖)
|
||||
const previewStyle: TitleSettings = { ...settings, ...(p.style as Partial<TitleSettings>) }
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
className={`ts-preset-card${isActive ? " active" : ""}`}
|
||||
onClick={() => onApply(p.key)}
|
||||
title={p.label}
|
||||
>
|
||||
<div className="ts-preset-preview">
|
||||
<TitleMiniPreview settings={previewStyle} width={100} sampleText="标题" />
|
||||
</div>
|
||||
<div className="ts-preset-meta">
|
||||
<span className="ts-preset-emoji">{p.emoji}</span>
|
||||
<span className="ts-preset-label">{p.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
|
||||
@@ -47,107 +185,352 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
titlePresets: _titlePresets,
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
showCoverToggle = false,
|
||||
previewWidth = 220,
|
||||
onUpdateStyle,
|
||||
}) => {
|
||||
const upd = (patch: Partial<TitleSettings>) => {
|
||||
onUpdateStyle?.(patch)
|
||||
}
|
||||
|
||||
/* 封面独立标题切换 */
|
||||
const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle)
|
||||
|
||||
return (
|
||||
<div className="xx-title-style-section">
|
||||
<h4 className="xx-section-subtitle">标题样式</h4>
|
||||
|
||||
{/* 位置 + 字体 一行 */}
|
||||
<div className="xx-title-style-row">
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>位置</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={settings.position}
|
||||
onChange={(e) => onUpdatePosition(e.target.value)}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="xx-form-field xx-half-field">
|
||||
<label>字体</label>
|
||||
<select
|
||||
className="xx-form-select"
|
||||
value={settings.font}
|
||||
onChange={(e) => onUpdateFont(e.target.value)}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字号滑块 */}
|
||||
<div className="xx-form-field">
|
||||
<div className="xx-field-label-row">
|
||||
<label>字号</label>
|
||||
<span className="xx-field-value">{settings.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
className="xx-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={128}
|
||||
value={settings.size}
|
||||
onChange={(e) => onUpdateSize(Number(e.target.value))}
|
||||
<div className="xx-title-style-section ts-panel">
|
||||
{/* 实时迷你预览 */}
|
||||
<div className="ts-preview-wrap">
|
||||
<TitleMiniPreview
|
||||
settings={settings}
|
||||
width={previewWidth}
|
||||
sampleText={settings.title || "预览标题文字"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设样式 */}
|
||||
<div className="xx-form-field">
|
||||
<label>预设样式</label>
|
||||
<TitlePresetsGrid
|
||||
presets={titlePresets}
|
||||
activePreset={activePreset}
|
||||
onApply={onApplyPreset}
|
||||
fontFamily={settings.font}
|
||||
/>
|
||||
{/* 预设样式(10个,含抖音爆款黄) */}
|
||||
<div className="ts-form-field">
|
||||
<label>爆款预设</label>
|
||||
<PresetGrid activePreset={activePreset} onApply={onApplyPreset} settings={settings} />
|
||||
</div>
|
||||
|
||||
{/* 样式按钮:粗体/斜体/描边/阴影 */}
|
||||
<div className="xx-form-field">
|
||||
<label>样式</label>
|
||||
<div className="xx-style-btns">
|
||||
<button
|
||||
className={`xx-style-btn ${settings.bold ? "active" : ""}`}
|
||||
onClick={onToggleBold}
|
||||
title="粗体"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${settings.italic ? "active" : ""}`}
|
||||
onClick={onToggleItalic}
|
||||
title="斜体"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${settings.stroke ? "active" : ""}`}
|
||||
onClick={onToggleStroke}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
className={`xx-style-btn ${settings.shadow ? "active" : ""}`}
|
||||
onClick={onToggleShadow}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
size="small"
|
||||
defaultActiveKey="basic"
|
||||
items={[
|
||||
{
|
||||
key: "basic",
|
||||
label: "基础",
|
||||
children: (
|
||||
<>
|
||||
{/* 位置 + 字体 */}
|
||||
<div className="ts-row-2">
|
||||
<div className="ts-form-field ts-half">
|
||||
<label>位置</label>
|
||||
<select
|
||||
className="ts-select"
|
||||
value={settings.position}
|
||||
onChange={(e) => onUpdatePosition(e.target.value)}
|
||||
>
|
||||
{POSITION_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="ts-form-field ts-half">
|
||||
<label>字体</label>
|
||||
<select
|
||||
className="ts-select"
|
||||
value={settings.font}
|
||||
onChange={(e) => onUpdateFont(e.target.value)}
|
||||
>
|
||||
{NEW_FONT_OPTIONS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.tag === "hot" ? "🔥 " : f.tag === "new" ? "🆕 " : ""}
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SliderRow
|
||||
label="字号"
|
||||
value={settings.size}
|
||||
min={16}
|
||||
max={120}
|
||||
onChange={onUpdateSize}
|
||||
/>
|
||||
|
||||
{/* 样式按钮 */}
|
||||
<div className="ts-form-field">
|
||||
<label>样式</label>
|
||||
<div className="ts-style-btns">
|
||||
<button
|
||||
type="button"
|
||||
className={`ts-style-btn${settings.bold ? " active" : ""}`}
|
||||
onClick={onToggleBold}
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ts-style-btn${settings.italic ? " active" : ""}`}
|
||||
onClick={onToggleItalic}
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ts-style-btn${settings.stroke ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onToggleStroke()
|
||||
// 如果之前 strokeWidth 为 0,启用时给个默认值
|
||||
if (!settings.stroke && (settings.strokeWidth ?? 0) < 2) {
|
||||
upd({ strokeWidth: 4 })
|
||||
}
|
||||
}}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ts-style-btn${settings.shadow ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onToggleShadow()
|
||||
if (!settings.shadow) {
|
||||
upd({
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 2,
|
||||
shadowBlur: 4,
|
||||
shadowColor: "rgba(0,0,0,0.8)",
|
||||
})
|
||||
}
|
||||
}}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字色 */}
|
||||
<ColorPicker
|
||||
label="字色"
|
||||
value={settings.color}
|
||||
palette={TITLE_COLOR_PALETTE}
|
||||
onChange={(c) => upd({ color: c })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "stroke",
|
||||
label: "描边",
|
||||
children: (
|
||||
<>
|
||||
<div className="ts-toggle-row">
|
||||
<label>
|
||||
<input type="checkbox" checked={settings.stroke} onChange={onToggleStroke} />
|
||||
启用描边
|
||||
</label>
|
||||
</div>
|
||||
{settings.stroke && (
|
||||
<>
|
||||
<SliderRow
|
||||
label="描边宽度"
|
||||
value={settings.strokeWidth ?? 4}
|
||||
min={0}
|
||||
max={20}
|
||||
onChange={(v) => upd({ strokeWidth: v })}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="描边颜色"
|
||||
value={settings.strokeColor ?? "#000000"}
|
||||
palette={STROKE_COLOR_PALETTE}
|
||||
onChange={(c) => upd({ strokeColor: c })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "shadow",
|
||||
label: "阴影",
|
||||
children: (
|
||||
<>
|
||||
<div className="ts-toggle-row">
|
||||
<label>
|
||||
<input type="checkbox" checked={settings.shadow} onChange={onToggleShadow} />
|
||||
启用阴影
|
||||
</label>
|
||||
</div>
|
||||
{settings.shadow && (
|
||||
<>
|
||||
<SliderRow
|
||||
label="X偏移"
|
||||
value={settings.shadowOffsetX ?? 2}
|
||||
min={-20}
|
||||
max={20}
|
||||
onChange={(v) => upd({ shadowOffsetX: v })}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Y偏移"
|
||||
value={settings.shadowOffsetY ?? 2}
|
||||
min={-20}
|
||||
max={20}
|
||||
onChange={(v) => upd({ shadowOffsetY: v })}
|
||||
/>
|
||||
<SliderRow
|
||||
label="模糊半径"
|
||||
value={settings.shadowBlur ?? 4}
|
||||
min={0}
|
||||
max={30}
|
||||
onChange={(v) => upd({ shadowBlur: v })}
|
||||
/>
|
||||
<div className="ts-form-field">
|
||||
<label>阴影颜色</label>
|
||||
<input
|
||||
type="text"
|
||||
className="ts-input"
|
||||
value={settings.shadowColor ?? "rgba(0,0,0,0.8)"}
|
||||
onChange={(e) => upd({ shadowColor: e.target.value })}
|
||||
placeholder="rgba(0,0,0,0.8)"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "bg",
|
||||
label: "背景",
|
||||
children: (
|
||||
<>
|
||||
<div className="ts-toggle-row">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.bgEnabled}
|
||||
onChange={() => upd({ bgEnabled: !settings.bgEnabled })}
|
||||
/>
|
||||
启用背景色块
|
||||
</label>
|
||||
</div>
|
||||
{settings.bgEnabled && (
|
||||
<>
|
||||
<ColorPicker
|
||||
label="背景颜色(含透明度)"
|
||||
value={settings.bgColor}
|
||||
palette={BG_COLOR_PALETTE}
|
||||
onChange={(c) => upd({ bgColor: c })}
|
||||
/>
|
||||
<SliderRow
|
||||
label="内边距"
|
||||
value={settings.bgPadding}
|
||||
min={0}
|
||||
max={40}
|
||||
onChange={(v) => upd({ bgPadding: v })}
|
||||
/>
|
||||
<SliderRow
|
||||
label="圆角"
|
||||
value={settings.bgRadius}
|
||||
min={0}
|
||||
max={30}
|
||||
onChange={(v) => upd({ bgRadius: v })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "layout",
|
||||
label: "排版",
|
||||
children: (
|
||||
<>
|
||||
<SliderRow
|
||||
label="每行最大字符数"
|
||||
value={settings.maxCharsPerLine ?? 0}
|
||||
min={0}
|
||||
max={20}
|
||||
unit=""
|
||||
onChange={(v) => upd({ maxCharsPerLine: v })}
|
||||
/>
|
||||
<div
|
||||
className="ts-form-field"
|
||||
style={{ fontSize: 11, color: "#9ca3af", marginTop: -4 }}
|
||||
>
|
||||
0 = 不自动换行(按 / 手动分行)
|
||||
</div>
|
||||
<SliderRow
|
||||
label="行距倍数"
|
||||
value={Math.round((settings.lineHeight ?? 1.2) * 100) / 100}
|
||||
min={1}
|
||||
max={2}
|
||||
step={0.05}
|
||||
unit=""
|
||||
onChange={(v) => upd({ lineHeight: Number(v.toFixed(2)) })}
|
||||
/>
|
||||
<SliderRow
|
||||
label="顶部边距"
|
||||
value={settings.marginTop ?? 24}
|
||||
min={0}
|
||||
max={200}
|
||||
onChange={(v) => upd({ marginTop: v })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
...(showCoverToggle
|
||||
? [
|
||||
{
|
||||
key: "cover",
|
||||
label: "封面",
|
||||
children: (
|
||||
<>
|
||||
<div className="ts-toggle-row">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={coverOpen}
|
||||
onChange={(e) => {
|
||||
setCoverOpen(e.target.checked)
|
||||
if (!e.target.checked) {
|
||||
upd({ coverTitle: null })
|
||||
} else {
|
||||
upd({
|
||||
coverTitle: {
|
||||
font: settings.font,
|
||||
size: Math.round(settings.size * 0.9),
|
||||
color: settings.color,
|
||||
bold: settings.bold,
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
封面使用独立标题样式
|
||||
</label>
|
||||
</div>
|
||||
{coverOpen && settings.coverTitle && (
|
||||
<div style={{ fontSize: 12, color: "#6b7280", lineHeight: 1.6 }}>
|
||||
封面样式已开启。可在「封面设置」面板单独调整封面标题的字体/字号/颜色。
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,11 +54,28 @@ export const POSITION_OPTIONS = [
|
||||
{ value: "custom", label: "自定义" },
|
||||
]
|
||||
|
||||
/* ── 标题字体选项 ── */
|
||||
export const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "微软雅黑", "楷体"]
|
||||
/* ── 标题字体选项(#2001:新增 4 款爆款字体) ── */
|
||||
export const FONT_OPTIONS = [
|
||||
"优设标题黑",
|
||||
"阿里普惠体Bold",
|
||||
"抖音美好体",
|
||||
"思源黑体Heavy",
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
]
|
||||
|
||||
/* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */
|
||||
export const FONT_FAMILY_MAP: Record<string, string> = {
|
||||
优设标题黑:
|
||||
'"YouSheBiaoTiHei","YouShe Title Black","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif',
|
||||
阿里普惠体Bold:
|
||||
'"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif',
|
||||
抖音美好体: '"Douyin Sans","DouyinSansBold","Source Han Sans SC Heavy","PingFang SC",sans-serif',
|
||||
思源黑体Heavy:
|
||||
'"Source Han Sans SC Heavy","Noto Sans SC Heavy","Source Han Sans CN Heavy","PingFang SC",sans-serif',
|
||||
思源黑体: '"Source Han Sans SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
思源宋体: '"Source Han Serif SC", "Noto Serif SC", "Songti SC", "SimSun", serif',
|
||||
苹方: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface UseGenerateVideoProps {
|
||||
ttsVoiceId?: string
|
||||
/** TTS 音色来源 */
|
||||
ttsVoiceSource?: "preset" | "clone"
|
||||
/** TTS 配音风格 */
|
||||
ttsStyle?: string
|
||||
/** 合成后保存到配音库的 asset id / job id(叙事模式) */
|
||||
ttsVoiceAssetId?: string
|
||||
/** 智能降重开关(默认 true) */
|
||||
|
||||
@@ -30,8 +30,23 @@ interface UseBatchCoversOptions {
|
||||
color: string
|
||||
position: string
|
||||
bold: boolean
|
||||
italic?: boolean
|
||||
stroke: boolean
|
||||
strokeWidth?: number
|
||||
strokeColor?: string
|
||||
shadow: boolean
|
||||
shadowOffsetX?: number
|
||||
shadowOffsetY?: number
|
||||
shadowBlur?: number
|
||||
shadowColor?: string
|
||||
lineHeight?: number
|
||||
marginTop?: number
|
||||
maxCharsPerLine?: number
|
||||
bgEnabled?: boolean
|
||||
bgColor?: string
|
||||
bgPadding?: number
|
||||
bgRadius?: number
|
||||
lineOverrides?: unknown[]
|
||||
}
|
||||
covers: string[]
|
||||
onCoversChange: CoversChangeFn
|
||||
@@ -100,8 +115,37 @@ export function useBatchCovers({
|
||||
font_color: titleStyle.color,
|
||||
position: titleStyle.position,
|
||||
bold: titleStyle.bold,
|
||||
stroke: titleStyle.stroke,
|
||||
shadow: titleStyle.shadow,
|
||||
italic: titleStyle.italic,
|
||||
stroke: titleStyle.stroke
|
||||
? {
|
||||
enabled: true,
|
||||
width: titleStyle.strokeWidth ?? 4,
|
||||
color: titleStyle.strokeColor ?? "#000000",
|
||||
}
|
||||
: { enabled: false },
|
||||
shadow: titleStyle.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
offset_x: titleStyle.shadowOffsetX ?? 2,
|
||||
offset_y: titleStyle.shadowOffsetY ?? 2,
|
||||
blur: titleStyle.shadowBlur ?? 4,
|
||||
color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)",
|
||||
}
|
||||
: { enabled: false },
|
||||
line_height: titleStyle.lineHeight ?? 1.2,
|
||||
margin_top: titleStyle.marginTop ?? 24,
|
||||
max_chars_per_line: titleStyle.maxCharsPerLine ?? 0,
|
||||
background: titleStyle.bgEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
color: titleStyle.bgColor,
|
||||
padding: titleStyle.bgPadding,
|
||||
radius: titleStyle.bgRadius,
|
||||
}
|
||||
: { enabled: false },
|
||||
line_overrides: (titleStyle.lineOverrides ?? []) as Array<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { ScriptItem } from "@/api/scripts"
|
||||
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
|
||||
import { DEFAULT_COVER_SETTINGS, DEFAULT_CLIP_COUNT } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { usePlanConfigLoader } from "./usePlanConfigLoader"
|
||||
@@ -33,6 +34,21 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
color: "#ffffff",
|
||||
posX: null,
|
||||
posY: null,
|
||||
lineHeight: 1.2,
|
||||
marginTop: 24,
|
||||
maxCharsPerLine: 0,
|
||||
strokeWidth: 4,
|
||||
strokeColor: "#000000",
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 2,
|
||||
shadowBlur: 4,
|
||||
shadowColor: "rgba(0,0,0,0.8)",
|
||||
bgEnabled: false,
|
||||
bgColor: "rgba(0,0,0,0.5)",
|
||||
bgPadding: 12,
|
||||
bgRadius: 8,
|
||||
lineOverrides: [],
|
||||
coverTitle: null,
|
||||
}
|
||||
|
||||
export interface GenerateFormState {
|
||||
@@ -95,6 +111,9 @@ export interface GenerateFormState {
|
||||
/** TTS 音色来源:preset 系统 / clone 克隆 */
|
||||
ttsVoiceSource: "preset" | "clone"
|
||||
setTtsVoiceSource: (src: "preset" | "clone") => void
|
||||
/** TTS 配音风格 */
|
||||
ttsStyle: TtsStyle
|
||||
setTtsStyle: (s: TtsStyle) => void
|
||||
/** 合成后配音库 asset id(叙事模式保存到库后获得;随机模式 = selectedVoice) */
|
||||
ttsVoiceAssetId: string
|
||||
setTtsVoiceAssetId: (id: string) => void
|
||||
@@ -234,6 +253,7 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [selectedScript, setSelectedScript] = useState<ScriptItem | null>(null)
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
|
||||
const [ttsVoiceSource, setTtsVoiceSource] = useState<"preset" | "clone">("preset")
|
||||
const [ttsStyle, setTtsStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
|
||||
const [ttsVoiceAssetId, setTtsVoiceAssetId] = useState<string>("")
|
||||
const [dedupEnabled, setDedupEnabled] = useState<boolean>(true)
|
||||
|
||||
@@ -311,6 +331,8 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
setTtsVoiceId,
|
||||
ttsVoiceSource,
|
||||
setTtsVoiceSource,
|
||||
ttsStyle,
|
||||
setTtsStyle,
|
||||
ttsVoiceAssetId,
|
||||
setTtsVoiceAssetId,
|
||||
dedupEnabled,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from "react"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { TitleLineOverride } from "@/components/title/types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { getEditPlan } from "@/api/template-editor"
|
||||
|
||||
@@ -12,6 +13,156 @@ interface UsePlanConfigLoaderOptions {
|
||||
setSelectedMaterials: (ids: string[]) => void
|
||||
}
|
||||
|
||||
/** #2001:统一归一化 title_config snake_case -> camelCase TitleSettings */
|
||||
function mapTitleCfgToSettings(
|
||||
prev: TitleSettings,
|
||||
tc: TitleConfig & Record<string, unknown>,
|
||||
): TitleSettings {
|
||||
const stroke = tc.stroke as
|
||||
boolean | { enabled?: boolean; width?: number; color?: string } | undefined
|
||||
const strokeEnabled: boolean | undefined =
|
||||
typeof stroke === "object" && stroke ? stroke.enabled !== false : !!stroke || undefined
|
||||
const strokeW: number | undefined =
|
||||
typeof stroke === "object" && stroke
|
||||
? (stroke.width ?? (tc.stroke_width as number | undefined))
|
||||
: (tc.stroke_width as number | undefined)
|
||||
const strokeC: string | undefined =
|
||||
typeof stroke === "object" && stroke
|
||||
? (stroke.color ?? (tc.stroke_color as string | undefined))
|
||||
: (tc.stroke_color as string | undefined)
|
||||
|
||||
const shadow = tc.shadow as
|
||||
| boolean
|
||||
| { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string }
|
||||
| undefined
|
||||
const shadowEnabled: boolean | undefined =
|
||||
typeof shadow === "object" && shadow ? shadow.enabled !== false : !!shadow || undefined
|
||||
const shOffX: number | undefined =
|
||||
typeof shadow === "object" && shadow
|
||||
? (shadow.offset_x ?? (tc.shadow_offset_x as number | undefined))
|
||||
: (tc.shadow_offset_x as number | undefined)
|
||||
const shOffY: number | undefined =
|
||||
typeof shadow === "object" && shadow
|
||||
? (shadow.offset_y ?? (tc.shadow_offset_y as number | undefined))
|
||||
: (tc.shadow_offset_y as number | undefined)
|
||||
const shBlur: number | undefined =
|
||||
typeof shadow === "object" && shadow
|
||||
? (shadow.blur ?? (tc.shadow_blur as number | undefined))
|
||||
: (tc.shadow_blur as number | undefined)
|
||||
const shColor: string | undefined =
|
||||
typeof shadow === "object" && shadow
|
||||
? (shadow.color ?? (tc.shadow_color as string | undefined))
|
||||
: (tc.shadow_color as string | undefined)
|
||||
|
||||
const bg = tc.background as
|
||||
{ enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined
|
||||
const bgEnabled: boolean | undefined =
|
||||
(bg && typeof bg === "object" ? bg.enabled : undefined) ??
|
||||
(tc.bg_enabled as boolean | undefined)
|
||||
const bgColor: string | undefined =
|
||||
(bg && typeof bg === "object" ? bg.color : undefined) ?? (tc.bg_color as string | undefined)
|
||||
const bgPadding: number | undefined =
|
||||
(bg && typeof bg === "object" ? bg.padding : undefined) ?? (tc.bg_padding as number | undefined)
|
||||
const bgRadius: number | undefined =
|
||||
(bg && typeof bg === "object" ? bg.radius : undefined) ?? (tc.bg_radius as number | undefined)
|
||||
|
||||
const ct = (tc.cover_title_config ?? null) as null | Record<string, unknown>
|
||||
let coverTitle: TitleSettings["coverTitle"] = prev.coverTitle
|
||||
if (ct) {
|
||||
const ctStroke = ct.stroke as
|
||||
boolean | { enabled?: boolean; width?: number; color?: string } | undefined
|
||||
const ctShadow = ct.shadow as
|
||||
| boolean
|
||||
| { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string }
|
||||
| undefined
|
||||
const ctBg = ct.background as
|
||||
{ enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined
|
||||
coverTitle = {
|
||||
title: (ct.title as string | undefined) ?? prev.coverTitle?.title ?? "",
|
||||
font: (ct.font as string | undefined) ?? prev.coverTitle?.font,
|
||||
size:
|
||||
(ct.font_size as number | undefined) ??
|
||||
(ct.size as number | undefined) ??
|
||||
prev.coverTitle?.size,
|
||||
color:
|
||||
(ct.font_color as string | undefined) ??
|
||||
(ct.color as string | undefined) ??
|
||||
prev.coverTitle?.color,
|
||||
bold: (ct.bold as boolean | undefined) ?? prev.coverTitle?.bold,
|
||||
italic: (ct.italic as boolean | undefined) ?? prev.coverTitle?.italic,
|
||||
position: (ct.position as string | undefined) ?? prev.coverTitle?.position,
|
||||
stroke:
|
||||
typeof ctStroke === "object" && ctStroke
|
||||
? ctStroke.enabled !== false
|
||||
: ((ctStroke as boolean | undefined) ?? prev.coverTitle?.stroke),
|
||||
strokeWidth:
|
||||
(typeof ctStroke === "object" && ctStroke ? ctStroke.width : undefined) ??
|
||||
(ct.stroke_width as number | undefined) ??
|
||||
prev.coverTitle?.strokeWidth,
|
||||
strokeColor:
|
||||
(typeof ctStroke === "object" && ctStroke ? ctStroke.color : undefined) ??
|
||||
(ct.stroke_color as string | undefined) ??
|
||||
prev.coverTitle?.strokeColor,
|
||||
shadow:
|
||||
typeof ctShadow === "object" && ctShadow
|
||||
? ctShadow.enabled !== false
|
||||
: ((ctShadow as boolean | undefined) ?? prev.coverTitle?.shadow),
|
||||
shadowOffsetX:
|
||||
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_x : undefined) ??
|
||||
(ct.shadow_offset_x as number | undefined) ??
|
||||
prev.coverTitle?.shadowOffsetX,
|
||||
shadowOffsetY:
|
||||
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_y : undefined) ??
|
||||
(ct.shadow_offset_y as number | undefined) ??
|
||||
prev.coverTitle?.shadowOffsetY,
|
||||
shadowBlur:
|
||||
(typeof ctShadow === "object" && ctShadow ? ctShadow.blur : undefined) ??
|
||||
(ct.shadow_blur as number | undefined) ??
|
||||
prev.coverTitle?.shadowBlur,
|
||||
shadowColor:
|
||||
(typeof ctShadow === "object" && ctShadow ? ctShadow.color : undefined) ??
|
||||
(ct.shadow_color as string | undefined) ??
|
||||
prev.coverTitle?.shadowColor,
|
||||
bgEnabled:
|
||||
ctBg?.enabled ?? (ct.bg_enabled as boolean | undefined) ?? prev.coverTitle?.bgEnabled,
|
||||
bgColor: ctBg?.color ?? (ct.bg_color as string | undefined) ?? prev.coverTitle?.bgColor,
|
||||
bgPadding:
|
||||
ctBg?.padding ?? (ct.bg_padding as number | undefined) ?? prev.coverTitle?.bgPadding,
|
||||
bgRadius: ctBg?.radius ?? (ct.bg_radius as number | undefined) ?? prev.coverTitle?.bgRadius,
|
||||
}
|
||||
}
|
||||
|
||||
const result: TitleSettings = {
|
||||
...prev,
|
||||
title: (tc.content as string | undefined) || prev.title,
|
||||
aiAutoSelect: (tc.ai_auto_select as boolean | undefined) || false,
|
||||
position: prev.position,
|
||||
font: (tc.font_preset as string | undefined) || prev.font,
|
||||
size: (tc.font_size as number | undefined) || prev.size,
|
||||
color: (tc.font_color as string | undefined) || prev.color,
|
||||
bold: (tc.bold as boolean | undefined) ?? prev.bold,
|
||||
italic: (tc.italic as boolean | undefined) ?? prev.italic,
|
||||
stroke: strokeEnabled ?? prev.stroke,
|
||||
strokeWidth: strokeW ?? prev.strokeWidth,
|
||||
strokeColor: strokeC ?? prev.strokeColor,
|
||||
shadow: shadowEnabled ?? prev.shadow,
|
||||
shadowOffsetX: shOffX ?? prev.shadowOffsetX,
|
||||
shadowOffsetY: shOffY ?? prev.shadowOffsetY,
|
||||
shadowBlur: shBlur ?? prev.shadowBlur,
|
||||
shadowColor: shColor ?? prev.shadowColor,
|
||||
lineHeight: (tc.line_height as number | undefined) ?? prev.lineHeight,
|
||||
marginTop: (tc.margin_top as number | undefined) ?? prev.marginTop,
|
||||
maxCharsPerLine: (tc.max_chars_per_line as number | undefined) ?? prev.maxCharsPerLine,
|
||||
bgEnabled: bgEnabled ?? prev.bgEnabled,
|
||||
bgColor: bgColor ?? prev.bgColor,
|
||||
bgPadding: bgPadding ?? prev.bgPadding,
|
||||
bgRadius: bgRadius ?? prev.bgRadius,
|
||||
lineOverrides: ((tc.line_overrides as unknown[] | undefined) ?? []) as TitleLineOverride[],
|
||||
coverTitle,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 参数或编辑计划 ID 加载表单配置
|
||||
*/
|
||||
@@ -27,14 +178,7 @@ export function usePlanConfigLoader({
|
||||
if (!planConfigStr) return
|
||||
try {
|
||||
const config = JSON.parse(planConfigStr) as {
|
||||
title_config?: {
|
||||
content?: string
|
||||
ai_auto_select?: boolean
|
||||
position?: string
|
||||
font_preset?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
}
|
||||
title_config?: Record<string, unknown>
|
||||
subtitle_config?: { enabled?: boolean }
|
||||
bgm_config?: { enabled?: boolean; music_id?: string }
|
||||
mode?: string
|
||||
@@ -43,16 +187,8 @@ export function usePlanConfigLoader({
|
||||
}
|
||||
|
||||
if (config.title_config) {
|
||||
const tc = config.title_config as TitleConfig
|
||||
setTitleSettings((prev: TitleSettings) => ({
|
||||
...prev,
|
||||
title: tc.content || "",
|
||||
aiAutoSelect: tc.ai_auto_select || false,
|
||||
position: prev.position, // 强制保留默认/用户选择,不从草稿配置同步位置
|
||||
font: tc.font_preset || prev.font,
|
||||
size: tc.font_size || prev.size,
|
||||
color: tc.font_color || prev.color,
|
||||
}))
|
||||
const tc = config.title_config as TitleConfig & Record<string, unknown>
|
||||
setTitleSettings((prev: TitleSettings) => mapTitleCfgToSettings(prev, tc))
|
||||
}
|
||||
if (config.segments && config.segments.length > 0) {
|
||||
const assetIds = config.segments
|
||||
@@ -76,15 +212,8 @@ export function usePlanConfigLoader({
|
||||
if (plan.name) setTitleSettings((prev: TitleSettings) => ({ ...prev, title: plan.name }))
|
||||
const cfg = plan.config
|
||||
if (cfg?.title_config) {
|
||||
setTitleSettings((prev: TitleSettings) => ({
|
||||
...prev,
|
||||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||||
title: cfg.title_config!.content || prev.title,
|
||||
position: prev.position, // 强制保留默认/用户选择,不从远程草稿同步位置
|
||||
font: cfg.title_config!.font_preset || prev.font,
|
||||
size: cfg.title_config!.font_size || prev.size,
|
||||
color: cfg.title_config!.font_color || prev.color,
|
||||
}))
|
||||
const tc2 = cfg.title_config as unknown as TitleConfig & Record<string, unknown>
|
||||
setTitleSettings((prev: TitleSettings) => mapTitleCfgToSettings(prev, tc2))
|
||||
}
|
||||
if (cfg?.cover_config) {
|
||||
const cc = cfg.cover_config as CoverConfig
|
||||
|
||||
@@ -208,6 +208,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
script_id: props.selectedScript.id,
|
||||
tts_voice_id: props.ttsVoiceId || undefined,
|
||||
tts_voice_source: props.ttsVoiceSource || undefined,
|
||||
tts_style: props.ttsStyle || undefined,
|
||||
}
|
||||
: {}),
|
||||
dedup_enabled: dedupEnabled,
|
||||
@@ -240,8 +241,91 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}
|
||||
: {}),
|
||||
bold: props.titleSettings.bold,
|
||||
stroke: props.titleSettings.stroke,
|
||||
shadow: props.titleSettings.shadow,
|
||||
italic: props.titleSettings.italic,
|
||||
stroke: props.titleSettings.stroke
|
||||
? {
|
||||
enabled: true,
|
||||
width: props.titleSettings.strokeWidth ?? 4,
|
||||
color: props.titleSettings.strokeColor ?? "#000000",
|
||||
}
|
||||
: { enabled: false },
|
||||
shadow: props.titleSettings.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
offset_x: props.titleSettings.shadowOffsetX ?? 2,
|
||||
offset_y: props.titleSettings.shadowOffsetY ?? 2,
|
||||
blur: props.titleSettings.shadowBlur ?? 4,
|
||||
color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)",
|
||||
}
|
||||
: { enabled: false },
|
||||
line_height: props.titleSettings.lineHeight ?? 1.2,
|
||||
margin_top: props.titleSettings.marginTop ?? 24,
|
||||
max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0,
|
||||
...(props.titleSettings.bgEnabled
|
||||
? {
|
||||
background: {
|
||||
enabled: true,
|
||||
color: props.titleSettings.bgColor,
|
||||
padding: props.titleSettings.bgPadding,
|
||||
radius: props.titleSettings.bgRadius,
|
||||
},
|
||||
}
|
||||
: { background: { enabled: false } }),
|
||||
line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({
|
||||
line_index: lo.line_index,
|
||||
text: lo.text,
|
||||
size: lo.size,
|
||||
color: lo.color,
|
||||
bold: lo.bold,
|
||||
italic: lo.italic,
|
||||
stroke: lo.stroke,
|
||||
highlights: lo.highlights?.map((h) => ({
|
||||
word: h.word,
|
||||
color: h.color,
|
||||
bold: h.bold,
|
||||
scale: h.scale,
|
||||
})),
|
||||
})),
|
||||
...(props.titleSettings.coverTitle
|
||||
? {
|
||||
cover_title_config: {
|
||||
title: props.titleSettings.coverTitle.title,
|
||||
font: props.titleSettings.coverTitle.font,
|
||||
font_size: props.titleSettings.coverTitle.size,
|
||||
font_color: props.titleSettings.coverTitle.color,
|
||||
bold: props.titleSettings.coverTitle.bold,
|
||||
italic: props.titleSettings.coverTitle.italic,
|
||||
position: props.titleSettings.coverTitle.position,
|
||||
stroke: props.titleSettings.coverTitle.stroke
|
||||
? {
|
||||
enabled: true,
|
||||
width: props.titleSettings.coverTitle.strokeWidth ?? 4,
|
||||
color: props.titleSettings.coverTitle.strokeColor ?? "#000000",
|
||||
}
|
||||
: { enabled: false },
|
||||
shadow: props.titleSettings.coverTitle.shadow
|
||||
? {
|
||||
enabled: true,
|
||||
offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2,
|
||||
offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2,
|
||||
blur: props.titleSettings.coverTitle.shadowBlur ?? 4,
|
||||
color:
|
||||
props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)",
|
||||
}
|
||||
: { enabled: false },
|
||||
...(props.titleSettings.coverTitle.bgEnabled
|
||||
? {
|
||||
background: {
|
||||
enabled: true,
|
||||
color: props.titleSettings.coverTitle.bgColor,
|
||||
padding: props.titleSettings.coverTitle.bgPadding,
|
||||
radius: props.titleSettings.coverTitle.bgRadius,
|
||||
},
|
||||
}
|
||||
: { background: { enabled: false } }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { TITLE_PRESETS } from "../../constants"
|
||||
import { TITLE_PRESETS as NEW_TITLE_PRESETS } from "@/components/title/constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
interface UseTitleStyleUpdatersOptions {
|
||||
@@ -97,26 +98,45 @@ export function useTitleStyleUpdaters({
|
||||
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
/** 应用预设:只覆盖 color/bold/italic/stroke/shadow,不改变字号 */
|
||||
/** 应用预设(支持新预设细粒度字段) */
|
||||
const applyPreset = useCallback(
|
||||
(presetKey: string) => {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
// 优先匹配新预设(10个爆款预设),fallback 旧预设
|
||||
const newPreset = NEW_TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
const oldPreset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (newPreset) {
|
||||
onTitleSettingsChange({
|
||||
...titleSettings,
|
||||
...(newPreset.style as Partial<TitleSettings>),
|
||||
// 清除逐行覆盖
|
||||
lineOverrides: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!oldPreset) return
|
||||
onTitleSettingsChange({
|
||||
...titleSettings,
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
color: oldPreset.style.color,
|
||||
bold: oldPreset.style.bold,
|
||||
italic: oldPreset.style.italic,
|
||||
stroke: oldPreset.style.stroke,
|
||||
shadow: oldPreset.style.shadow,
|
||||
})
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
/** 通用字段更新(patch) */
|
||||
const updateStyle = useCallback(
|
||||
(patch: Partial<TitleSettings>) => {
|
||||
onTitleSettingsChange({ ...titleSettings, ...patch })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
return {
|
||||
activePreset,
|
||||
titlePresets: TITLE_PRESETS,
|
||||
titlePresets: NEW_TITLE_PRESETS,
|
||||
updateTitle,
|
||||
toggleAiAutoSelect,
|
||||
updatePosition,
|
||||
@@ -129,5 +149,6 @@ export function useTitleStyleUpdaters({
|
||||
toggleStroke,
|
||||
toggleShadow,
|
||||
applyPreset,
|
||||
updateStyle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TitleLineOverride } from "@/components/title/types"
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
/* ── 标题设置(#2001 升级:新增描边/阴影/背景/逐行/封面独立样式/排版字段) ── */
|
||||
export interface TitleSettings {
|
||||
aiAutoSelect: boolean
|
||||
title: string
|
||||
@@ -19,6 +20,58 @@ export interface TitleSettings {
|
||||
/** 自由位置坐标(PlayRes 像素),仅当 position="custom" 时有效 */
|
||||
posX: number | null
|
||||
posY: number | null
|
||||
|
||||
/* ── 排版(P0) ── */
|
||||
/** 行距倍数,默认 1.2 */
|
||||
lineHeight: number
|
||||
/** 顶部边距(position=top,px @720p) */
|
||||
marginTop: number
|
||||
/** 每行最大字符数(4-20),0=不自动换行 */
|
||||
maxCharsPerLine: number
|
||||
|
||||
/* ── 描边参数化(P0) ── */
|
||||
strokeWidth: number
|
||||
strokeColor: string
|
||||
|
||||
/* ── 阴影参数化(P1) ── */
|
||||
shadowOffsetX: number
|
||||
shadowOffsetY: number
|
||||
shadowBlur: number
|
||||
shadowColor: string
|
||||
|
||||
/* ── 背景色块(P1) ── */
|
||||
bgEnabled: boolean
|
||||
bgColor: string
|
||||
bgPadding: number
|
||||
bgRadius: number
|
||||
|
||||
/* ── 逐行独立样式(P1) ── */
|
||||
lineOverrides: TitleLineOverride[]
|
||||
|
||||
/* ── 封面独立标题(P1):null=沿用主标题 ── */
|
||||
coverTitle: null | {
|
||||
title?: string
|
||||
font?: string
|
||||
size?: number
|
||||
color?: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
position?: string
|
||||
stroke?: boolean
|
||||
strokeWidth?: number
|
||||
strokeColor?: string
|
||||
shadow?: boolean
|
||||
shadowOffsetX?: number
|
||||
shadowOffsetY?: number
|
||||
shadowBlur?: number
|
||||
shadowColor?: string
|
||||
bgEnabled?: boolean
|
||||
bgColor?: string
|
||||
bgPadding?: number
|
||||
bgRadius?: number
|
||||
lineHeight?: number
|
||||
maxCharsPerLine?: number
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 智能匹配结果 ── */
|
||||
@@ -49,28 +102,33 @@ export interface StepDef {
|
||||
label: string
|
||||
}
|
||||
|
||||
/* ── 标题预设样式 ── */
|
||||
export interface TitlePresetStyle {
|
||||
size: number
|
||||
color: string
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
}
|
||||
|
||||
export interface TitlePreset {
|
||||
key: string
|
||||
label: string
|
||||
style: TitlePresetStyle
|
||||
previewStyle: Record<string, string | number>
|
||||
}
|
||||
|
||||
/* ── 生成结果视频 ── */
|
||||
export interface GeneratedVideoResult {
|
||||
id: string
|
||||
url: string
|
||||
thumbnail: string
|
||||
duration: number
|
||||
title: string
|
||||
/** 旧版 TitleSettings 的默认值字段(P0/P1 新字段补齐默认值) */
|
||||
export const DEFAULT_TITLE_SETTINGS_FULL: TitleSettings = {
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "top",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
posX: null,
|
||||
posY: null,
|
||||
lineHeight: 1.2,
|
||||
marginTop: 24,
|
||||
maxCharsPerLine: 0,
|
||||
strokeWidth: 4,
|
||||
strokeColor: "#000000",
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 2,
|
||||
shadowBlur: 4,
|
||||
shadowColor: "rgba(0,0,0,0.8)",
|
||||
bgEnabled: false,
|
||||
bgColor: "rgba(0,0,0,0.5)",
|
||||
bgPadding: 12,
|
||||
bgRadius: 8,
|
||||
lineOverrides: [],
|
||||
coverTitle: null,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsStyle,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
@@ -107,6 +108,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
setTtsSpeed,
|
||||
setTtsStyle,
|
||||
handleTtsSynthesize,
|
||||
handleTtsSave,
|
||||
handleTtsClose,
|
||||
@@ -315,6 +317,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
text={ttsText}
|
||||
voiceId={ttsVoiceId}
|
||||
speed={ttsSpeed}
|
||||
style={ttsStyle}
|
||||
status={ttsStatus}
|
||||
audioUrl={ttsAudioUrl ?? ""}
|
||||
error={ttsError ?? ""}
|
||||
@@ -324,6 +327,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
onTextChange={setTtsText}
|
||||
onVoiceChange={setTtsVoiceId}
|
||||
onSpeedChange={setTtsSpeed}
|
||||
onStyleChange={setTtsStyle}
|
||||
onSynthesize={handleTtsSynthesize}
|
||||
onSave={handleTtsSave}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react"
|
||||
import { RobotOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
|
||||
import type { TtsStyle } from "@/api/tts/styles"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -20,6 +22,8 @@ interface TtsModalProps {
|
||||
text: string
|
||||
voiceId: string
|
||||
speed: number
|
||||
style: TtsStyle
|
||||
onStyleChange: (style: TtsStyle) => void
|
||||
status: TtsStatus
|
||||
audioUrl: string
|
||||
error: string
|
||||
@@ -39,6 +43,8 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
text,
|
||||
voiceId,
|
||||
speed,
|
||||
style,
|
||||
onStyleChange,
|
||||
status,
|
||||
audioUrl,
|
||||
error,
|
||||
@@ -143,6 +149,9 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 配音风格 */}
|
||||
<TtsStyleSelector value={style} onChange={onStyleChange} compact />
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<Button
|
||||
buttonType="primary"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
|
||||
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices"
|
||||
import { getVoiceClonesWithTotal, toVoiceClone } from "@/api/voice-clone"
|
||||
|
||||
@@ -18,6 +19,7 @@ export function useTtsSynthesize() {
|
||||
const [ttsText, setTtsText] = useState("")
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
|
||||
const [ttsSpeed, setTtsSpeed] = useState(1.0)
|
||||
const [ttsStyle, setTtsStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
|
||||
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
|
||||
@@ -59,6 +61,7 @@ export function useTtsSynthesize() {
|
||||
text: ttsText.trim(),
|
||||
voice_id: ttsVoiceId || undefined,
|
||||
speed: ttsSpeed,
|
||||
style: ttsStyle,
|
||||
})
|
||||
setTtsJobId(resp.job_id)
|
||||
|
||||
@@ -89,7 +92,7 @@ export function useTtsSynthesize() {
|
||||
setTtsStatus("error")
|
||||
setTtsError(msg)
|
||||
}
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed])
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed, ttsStyle])
|
||||
|
||||
/** 保存 TTS 结果到素材库 */
|
||||
const handleTtsSave = useCallback(async () => {
|
||||
@@ -131,6 +134,7 @@ export function useTtsSynthesize() {
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsStyle,
|
||||
ttsJobId,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
@@ -141,6 +145,7 @@ export function useTtsSynthesize() {
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
setTtsSpeed,
|
||||
setTtsStyle,
|
||||
handleTtsSynthesize,
|
||||
handleTtsSave,
|
||||
handleTtsClose,
|
||||
|
||||
@@ -133,6 +133,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsEmotion,
|
||||
ttsStyle,
|
||||
ttsLanguage,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
@@ -140,6 +141,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
setTtsText,
|
||||
setTtsSpeed,
|
||||
setTtsEmotion,
|
||||
setTtsStyle,
|
||||
setTtsLanguage,
|
||||
setTtsOpen,
|
||||
handleVoiceChange,
|
||||
@@ -368,6 +370,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
ttsVoiceId={ttsVoiceId}
|
||||
ttsSpeed={ttsSpeed}
|
||||
ttsEmotion={ttsEmotion}
|
||||
ttsStyle={ttsStyle}
|
||||
ttsLanguage={ttsLanguage}
|
||||
ttsStatus={ttsStatus}
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
@@ -381,6 +384,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
onTtsVoiceChange={handleVoiceChange}
|
||||
onTtsSpeedChange={setTtsSpeed}
|
||||
onTtsEmotionChange={setTtsEmotion}
|
||||
onTtsStyleChange={setTtsStyle}
|
||||
onTtsLanguageChange={setTtsLanguage}
|
||||
onTtsSynthesize={handleTtsSynthesize}
|
||||
onTtsSave={handleTtsSave}
|
||||
|
||||
@@ -9,6 +9,7 @@ import LanguageControl from "./tts-modal/LanguageControl"
|
||||
import SynthesizeButton from "./tts-modal/SynthesizeButton"
|
||||
import ErrorAlert from "./tts-modal/ErrorAlert"
|
||||
import ResultPanel from "./tts-modal/ResultPanel"
|
||||
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
|
||||
import { PRESET_TTS_LANGUAGE_OPTIONS, CLONE_TTS_LANGUAGE_OPTIONS } from "./tts-modal/constants"
|
||||
|
||||
/** AI 配音弹窗 */
|
||||
@@ -18,6 +19,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsEmotion,
|
||||
ttsStyle,
|
||||
ttsLanguage,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
@@ -29,6 +31,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
onVoiceChange,
|
||||
onSpeedChange,
|
||||
onEmotionChange,
|
||||
onStyleChange,
|
||||
onLanguageChange,
|
||||
onSynthesize,
|
||||
onSave,
|
||||
@@ -72,6 +75,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
|
||||
<TtsStyleSelector value={ttsStyle} onChange={onStyleChange} compact />
|
||||
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
|
||||
{ttsError && <ErrorAlert error={ttsError} />}
|
||||
{ttsStatus === "done" && ttsAudioUrl && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { TtsStatus } from "./TtsModal"
|
||||
import type { TtsClonedVoiceOption } from "./tts-modal/VoiceSelector"
|
||||
import type { TtsEmotion, TtsLanguage } from "./tts-modal/constants"
|
||||
import type { TtsStyle } from "@/api/tts/styles"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import CloneDetailModal from "./CloneDetailModal"
|
||||
import UploadVoiceModal from "./UploadVoiceModal"
|
||||
@@ -44,6 +45,7 @@ export interface VoiceModalsProps {
|
||||
ttsVoiceId: string
|
||||
ttsSpeed: number
|
||||
ttsEmotion: TtsEmotion
|
||||
ttsStyle: TtsStyle
|
||||
ttsLanguage: TtsLanguage
|
||||
ttsStatus: TtsStatus
|
||||
ttsAudioUrl: string | null
|
||||
@@ -56,6 +58,7 @@ export interface VoiceModalsProps {
|
||||
onTtsVoiceChange: (id: string) => void
|
||||
onTtsSpeedChange: (speed: number) => void
|
||||
onTtsEmotionChange: (emotion: TtsEmotion) => void
|
||||
onTtsStyleChange: (style: TtsStyle) => void
|
||||
onTtsLanguageChange: (language: TtsLanguage) => void
|
||||
onTtsSynthesize: () => void
|
||||
onTtsSave: () => void
|
||||
@@ -86,6 +89,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsEmotion,
|
||||
ttsStyle,
|
||||
ttsLanguage,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
@@ -97,6 +101,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
onTtsVoiceChange,
|
||||
onTtsSpeedChange,
|
||||
onTtsEmotionChange,
|
||||
onTtsStyleChange,
|
||||
onTtsLanguageChange,
|
||||
onTtsSynthesize,
|
||||
onTtsSave,
|
||||
@@ -139,6 +144,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
ttsVoiceId={ttsVoiceId}
|
||||
ttsSpeed={ttsSpeed}
|
||||
ttsEmotion={ttsEmotion}
|
||||
ttsStyle={ttsStyle}
|
||||
ttsLanguage={ttsLanguage}
|
||||
ttsStatus={ttsStatus}
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
@@ -150,6 +156,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
onVoiceChange={onTtsVoiceChange}
|
||||
onSpeedChange={onTtsSpeedChange}
|
||||
onEmotionChange={onTtsEmotionChange}
|
||||
onStyleChange={onTtsStyleChange}
|
||||
onLanguageChange={onTtsLanguageChange}
|
||||
onSynthesize={onTtsSynthesize}
|
||||
onSave={onTtsSave}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import type { TtsClonedVoiceOption } from "./VoiceSelector"
|
||||
import type { TtsEmotion, TtsLanguage } from "./constants"
|
||||
import type { TtsStyle } from "@/api/tts/styles"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -10,6 +11,7 @@ export interface TtsModalProps {
|
||||
ttsVoiceId: string
|
||||
ttsSpeed: number
|
||||
ttsEmotion: TtsEmotion
|
||||
ttsStyle: TtsStyle
|
||||
ttsLanguage: TtsLanguage
|
||||
ttsStatus: TtsStatus
|
||||
ttsAudioUrl: string | null
|
||||
@@ -22,6 +24,7 @@ export interface TtsModalProps {
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
onSpeedChange: (speed: number) => void
|
||||
onEmotionChange: (emotion: TtsEmotion) => void
|
||||
onStyleChange: (style: TtsStyle) => void
|
||||
onLanguageChange: (language: TtsLanguage) => void
|
||||
onSynthesize: () => void
|
||||
onSave: () => void
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type TtsEmotion,
|
||||
type TtsLanguage,
|
||||
} from "../components/tts-modal/constants"
|
||||
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -42,6 +43,7 @@ export function useTtsSynthesize({
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
|
||||
const [ttsSpeed, setTtsSpeed] = useState(1.0)
|
||||
const [ttsEmotion, setTtsEmotion] = useState<TtsEmotion>(DEFAULT_TTS_EMOTION)
|
||||
const [ttsStyle, setTtsStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
|
||||
const [ttsLanguage, setTtsLanguage] = useState<TtsLanguage>(DEFAULT_TTS_LANGUAGE)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
|
||||
@@ -83,6 +85,7 @@ export function useTtsSynthesize({
|
||||
voice_id: ttsVoiceId || undefined,
|
||||
speed: ttsSpeed,
|
||||
emotion: ttsEmotion,
|
||||
style: ttsStyle,
|
||||
language: effectiveLang,
|
||||
})
|
||||
setTtsJobId(resp.job_id)
|
||||
@@ -114,7 +117,7 @@ export function useTtsSynthesize({
|
||||
setTtsStatus("error")
|
||||
setTtsError(msg)
|
||||
}
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed, ttsEmotion, ttsLanguage, clonedVoices])
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed, ttsEmotion, ttsStyle, ttsLanguage, clonedVoices])
|
||||
|
||||
/** 保存 TTS 结果到素材库 */
|
||||
const handleTtsSave = useCallback(async () => {
|
||||
@@ -137,6 +140,7 @@ export function useTtsSynthesize({
|
||||
setTtsVoiceId("")
|
||||
setTtsSpeed(1.0)
|
||||
setTtsEmotion(DEFAULT_TTS_EMOTION)
|
||||
setTtsStyle(DEFAULT_TTS_STYLE)
|
||||
setTtsLanguage(DEFAULT_TTS_LANGUAGE)
|
||||
setTtsStatus("idle")
|
||||
setTtsAudioUrl(null)
|
||||
@@ -168,6 +172,7 @@ export function useTtsSynthesize({
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsEmotion,
|
||||
ttsStyle,
|
||||
ttsLanguage,
|
||||
ttsJobId,
|
||||
ttsStatus,
|
||||
@@ -181,6 +186,7 @@ export function useTtsSynthesize({
|
||||
setTtsVoiceId,
|
||||
setTtsSpeed,
|
||||
setTtsEmotion,
|
||||
setTtsStyle,
|
||||
setTtsLanguage,
|
||||
setTtsOpen,
|
||||
// 覆写 onVoiceChange(带语言回退)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -48,6 +48,9 @@ celery_app.conf.imports = (
|
||||
# PYTHONPATH=/app/apps/api 下,app.tasks.lipsync_tts 可直接导入且不触发 apps/api/__init__.py
|
||||
# (apps/api/__init__.py 会 from .main import app,级联加载整个 FastAPI 栈,Worker 中不需要且会导致注册失败)
|
||||
"app.tasks.lipsync_tts",
|
||||
# #1998 GPU MuseTalk 异步推理:wait_for_result→签名 URL→回写 lipsync_jobs
|
||||
# 必须在 Worker 侧注册,否则 apply_async 消息无人消费,job 永远卡在 processing
|
||||
"app.tasks.lipsync_gpu",
|
||||
)
|
||||
|
||||
# Celery Beat 定时任务调度
|
||||
|
||||
+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
|
||||
@@ -115,8 +115,13 @@ def _register(task_id: Optional[str] = None) -> bool:
|
||||
last_heartbeat_at,防止长推理被误判超时回收。
|
||||
"""
|
||||
ok, info = _check_musetalk_health()
|
||||
free_vram = int(info.get("free_vram_mb", 0) or 0) if isinstance(info, dict) else 0
|
||||
gpu_name = info.get("gpu_name", "") if isinstance(info, dict) else ""
|
||||
if isinstance(info, dict):
|
||||
gpu_info = info.get("gpu", info)
|
||||
free_vram = int(gpu_info.get("free_vram_mb", gpu_info.get("memory_free_mb", 0)) or 0)
|
||||
gpu_name = gpu_info.get("gpu_name", info.get("gpu_name", ""))
|
||||
else:
|
||||
free_vram = 0
|
||||
gpu_name = ""
|
||||
if not gpu_name:
|
||||
# 尝试在 Windows 上读 nvidia-smi
|
||||
gpu_name = _probe_gpu_name()
|
||||
@@ -229,12 +234,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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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="$HOME/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,63 @@
|
||||
#!/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 ~/gpu-worker-update.log"
|
||||
echo "轮询日志:tail -f ~/gpu-poll.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="$HOME/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
|
||||
@@ -1,11 +1,11 @@
|
||||
[Unit]
|
||||
Description=MuseTalk GPU Worker (xiaoxia-saas 反向轮询)
|
||||
After=network.target musetalk.service
|
||||
# 本地 MuseTalk 服务启动后再启动本 Worker;若 MuseTalk 没有 systemd 服务则删除 musetalk.service
|
||||
After=network.target musetalk-worker.service
|
||||
# 本地 MuseTalk 服务(musetalk-worker.service)启动后再启动本 Worker
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%i
|
||||
User=ying
|
||||
WorkingDirectory=/opt/xiaoxia-gpu-worker
|
||||
# 读取环境变量(API 地址、Token、轮询间隔等)
|
||||
EnvironmentFile=/opt/xiaoxia-gpu-worker/.env
|
||||
|
||||
@@ -31,6 +31,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf
|
||||
COPY infra/fonts/NotoSerifCJKsc-VF.otf /usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf
|
||||
COPY infra/fonts/LXGWWenKai-Regular.ttf /usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf
|
||||
# #2001 爆款标题字体:优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy
|
||||
RUN mkdir -p /usr/share/fonts/truetype/xiaoxia
|
||||
COPY infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf /usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf
|
||||
COPY infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf /usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf
|
||||
COPY infra/fonts/xiaoxia/DouyinSansBold.otf /usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf
|
||||
COPY infra/fonts/xiaoxia/NotoSansSC-Black.otf /usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf
|
||||
RUN fc-cache -fv
|
||||
|
||||
# 创建虚拟环境
|
||||
|
||||
@@ -35,6 +35,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf
|
||||
COPY infra/fonts/NotoSerifCJKsc-VF.otf /usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf
|
||||
COPY infra/fonts/LXGWWenKai-Regular.ttf /usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf
|
||||
# #2001 爆款标题字体:优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy
|
||||
RUN mkdir -p /usr/share/fonts/truetype/xiaoxia
|
||||
COPY infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf /usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf
|
||||
COPY infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf /usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf
|
||||
COPY infra/fonts/xiaoxia/DouyinSansBold.otf /usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf
|
||||
COPY infra/fonts/xiaoxia/NotoSansSC-Black.otf /usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf
|
||||
RUN fc-cache -fv
|
||||
|
||||
# 创建虚拟环境
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -711,6 +711,7 @@ class LipsyncJobModel(Base):
|
||||
voice_id = Column(String(200), nullable=False, default="")
|
||||
script_text = Column(Text, nullable=False, default="")
|
||||
speed = Column(Float, nullable=False, default=1.0)
|
||||
style = Column(String(32), nullable=False, default="")
|
||||
emotion = Column(String(20), nullable=False, default="")
|
||||
|
||||
# MediaKit 任务状态
|
||||
@@ -750,6 +751,8 @@ class AiAvatarRenderJob(Base):
|
||||
# b_roll_segments 格式: [{"script_segment_index": 0, "asset_url": "...", "mode": "fullscreen|pip", "start_time": 5.0, "end_time": 10.0}, ...]
|
||||
title_config = Column(JSON, nullable=False, default=dict)
|
||||
cover_config = Column(JSON, nullable=False, default=dict)
|
||||
# #2001 封面独立标题配置(结构同 title_config;为空时封面不叠标题)
|
||||
cover_title_config = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
# 任务状态
|
||||
status = Column(String(20), nullable=False, default="pending", index=True)
|
||||
|
||||
@@ -26,12 +26,43 @@ from packages.shared.config import get_shared_settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# CosyVoice v3 情绪通过 input.instruction 中文自然语言指令控制(不再使用枚举 emotion 字段)。
|
||||
# 官方文档:instruction 格式严格为 "你说话的情感是<情感值>。",结尾中文句号不可省略;
|
||||
# 情感值必须是 7 种英文枚举之一:neutral/happy/sad/angry/surprised/fearful/disgusted。
|
||||
# ── Style(语气风格)→ CosyVoice instruct 自然语言指令 ──
|
||||
# 前端 PR#2002 传 6 种 style:natural/sweet/excited/professional/news/livestream。
|
||||
# style 是新的统一参数;emotion 为 deprecated 兼容别名,内部映射为 style。
|
||||
# 参考:https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list
|
||||
# 前端可传英文枚举或中文标签(中立/开心/难过/生气/惊讶/恐惧/厌恶),统一归一化为英文枚举。
|
||||
# 映射表 key(不区分大小写): 英文枚举/旧英文/中文标签 → 7 种标准英文枚举
|
||||
STYLE_INSTRUCTION_MAP: dict[str, str] = {
|
||||
"natural": "用自然、平和的语气说话。",
|
||||
"sweet": "用温柔甜美、亲切柔和的语气说话。",
|
||||
"excited": "用兴奋、激动的语气说话。",
|
||||
"professional": "用专业、正式的语气说话。",
|
||||
"news": "用新闻播报的语气说话。",
|
||||
"livestream": "用直播解说的语气说话。",
|
||||
}
|
||||
|
||||
# 有效 style 值集合(供 schema / 校验使用)
|
||||
VALID_STYLES: frozenset[str] = frozenset(STYLE_INSTRUCTION_MAP.keys())
|
||||
|
||||
# ── 旧 emotion → 新 style 兼容映射(方案 B:统一 style,emotion deprecated)──
|
||||
_EMOTION_TO_STYLE: dict[str, str] = {
|
||||
"neutral": "natural",
|
||||
"happy": "excited",
|
||||
"sad": "sweet",
|
||||
"angry": "excited",
|
||||
"surprised": "excited",
|
||||
"fearful": "sweet",
|
||||
"disgusted": "natural",
|
||||
}
|
||||
|
||||
# 严格格式系统音色:style → emotion 回退(用于无法使用自由文本指令的音色)
|
||||
_STYLE_TO_EMOTION: dict[str, str] = {
|
||||
"natural": "neutral",
|
||||
"sweet": "sad",
|
||||
"excited": "happy",
|
||||
"professional": "neutral",
|
||||
# news / livestream 无直接对应 emotion,特殊处理
|
||||
}
|
||||
|
||||
# ── 旧 emotion 映射表(deprecated,保留以兼容历史数据)──
|
||||
EMOTION_MAP: dict[str, str] = {
|
||||
# ── 7 种标准英文枚举(CosyVoice v3 官方支持的情感值)──
|
||||
"neutral": "neutral",
|
||||
@@ -122,6 +153,70 @@ def build_emotion_instruction(voice_id: str, emotion_enum: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_style(style: str = "", emotion: str = "") -> str:
|
||||
"""统一解析 style 参数(方案 B).
|
||||
|
||||
- style 有值且合法:直接使用(style 优先级最高)。
|
||||
- style 为空但 emotion 有值:将旧 emotion 归一化后映射为 style。
|
||||
- 两者皆空:返回空串(调用方不传 instruction)。
|
||||
|
||||
Args:
|
||||
style: 新的语气风格(natural/sweet/excited/professional/news/livestream)
|
||||
emotion: 旧的情绪参数(deprecated,内部映射为 style)
|
||||
|
||||
Returns:
|
||||
解析后的 style 字符串;无需 instruct 时返回空串
|
||||
"""
|
||||
s = (style or "").strip().lower()
|
||||
if s:
|
||||
if s in VALID_STYLES:
|
||||
return s
|
||||
logger.warning("未知的 style 值 %r,忽略 style 参数", style)
|
||||
# 回退:emotion → style
|
||||
norm = normalize_emotion(emotion)
|
||||
if not norm:
|
||||
return ""
|
||||
mapped = _EMOTION_TO_STYLE.get(norm)
|
||||
if not mapped:
|
||||
logger.warning("emotion %r 无法映射到 style,跳过 instruct", norm)
|
||||
return mapped or ""
|
||||
|
||||
|
||||
def build_style_instruction(voice_id: str, style: str) -> str:
|
||||
"""根据 voice 类型构造 style instruction.
|
||||
|
||||
- natural:返回空串(不额外加 instruct,使用 CosyVoice 默认自然语气)。
|
||||
- 克隆/设计音色:使用中文自然语言指令(DashScope 允许任意自然语言)。
|
||||
- 系统音色中支持 emotion instruct 的白名单音色:
|
||||
若 style 可映射到 emotion,用严格格式 "你说话的情感是<emotion>。";
|
||||
news/livestream 尝试直接用中文 instruct(部分音色支持自由文本)。
|
||||
- 其他系统音色:返回空串。
|
||||
|
||||
Args:
|
||||
voice_id: CosyVoice voice 参数
|
||||
style: 已通过 resolve_style() 解析的 style 值
|
||||
|
||||
Returns:
|
||||
拼接好的 instruction 字符串;无需 instruct 时返回空串
|
||||
"""
|
||||
if not style or style == "natural":
|
||||
return ""
|
||||
# 克隆音色:直接使用中文自然语言指令
|
||||
if _is_cloned_voice(voice_id):
|
||||
return STYLE_INSTRUCTION_MAP.get(style, "")
|
||||
# 系统音色白名单:优先映射到严格 emotion 格式
|
||||
if voice_id in _SYSTEM_VOICES_WITH_EMOTION_INSTRUCT:
|
||||
emotion_val = _STYLE_TO_EMOTION.get(style)
|
||||
if emotion_val:
|
||||
return f"你说话的情感是{emotion_val}。"
|
||||
# news/livestream 无 emotion 对应,尝试自由中文 instruct
|
||||
desc = STYLE_INSTRUCTION_MAP.get(style, "")
|
||||
if desc:
|
||||
logger.info("音色 %s 使用自由文本 style instruct: %s", voice_id, desc)
|
||||
return desc
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_emotion(emotion: str) -> str:
|
||||
"""将前端情绪值归一化为 CosyVoice v3 官方英文枚举,用于拼入 instruction.
|
||||
|
||||
@@ -573,6 +668,8 @@ class CosyVoiceService:
|
||||
volume: int = 50,
|
||||
emotion: str = "",
|
||||
language: str = "zh",
|
||||
style: str = "",
|
||||
pitch: float = 1.0,
|
||||
) -> dict:
|
||||
"""提交语音合成任务(同步非流式,直接返回结果).
|
||||
|
||||
@@ -586,9 +683,10 @@ class CosyVoiceService:
|
||||
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
|
||||
speed: 语速(0.5-2.0),1.0 为正常速度
|
||||
volume: 音量(0-100),默认 50
|
||||
emotion: 情绪,英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted
|
||||
或前端中文标签(中立/开心/难过/生气/惊讶/恐惧/厌恶),兼容旧值
|
||||
natural/excited/calm/friendly;空串不传,未知值默认 neutral
|
||||
style: 语气风格(natural/sweet/excited/professional/news/livestream),
|
||||
新的统一参数;优先级高于 emotion
|
||||
pitch: 音调(0.5-2.0),1.0 为默认值
|
||||
emotion: 【deprecated】旧情绪参数,内部通过 resolve_style() 映射为 style
|
||||
language: 语言代码(zh/en 等,默认 zh;系统音色仅 zh/en 传 language_hints)
|
||||
|
||||
Returns:
|
||||
@@ -617,11 +715,20 @@ class CosyVoiceService:
|
||||
"rate": speed,
|
||||
"volume": volume,
|
||||
}
|
||||
# 情绪 → instruction(按 voice 类型选择格式)
|
||||
norm_emotion = normalize_emotion(emotion)
|
||||
emotion_instruction = build_emotion_instruction(voice_id, norm_emotion)
|
||||
if emotion_instruction:
|
||||
input_payload["instruction"] = emotion_instruction
|
||||
# pitch: CosyVoice API 支持 [0.5, 2.0],非默认值时才传
|
||||
if pitch and pitch != 1.0:
|
||||
input_payload["pitch"] = pitch
|
||||
# style 优先:显式传 style 时走 build_style_instruction();
|
||||
# 无 style 时回退到旧的 emotion → build_emotion_instruction() 逻辑(向后兼容)
|
||||
s = (style or "").strip().lower()
|
||||
instruction = ""
|
||||
if s:
|
||||
instruction = build_style_instruction(voice_id, s)
|
||||
if not instruction:
|
||||
norm_emotion = normalize_emotion(emotion)
|
||||
instruction = build_emotion_instruction(voice_id, norm_emotion)
|
||||
if instruction:
|
||||
input_payload["instruction"] = instruction
|
||||
# 语言 → language_hints 数组(仅取第一个元素生效);
|
||||
# 系统音色(非克隆/非 voice_id 中包含下划线以外的短 ID)仅传 zh/en,其他语言不传避免报错
|
||||
norm_lang = normalize_language(language)
|
||||
@@ -682,6 +789,8 @@ class CosyVoiceService:
|
||||
emotion: str = "",
|
||||
language: str = "zh",
|
||||
timeout: float = 120.0,
|
||||
style: str = "",
|
||||
pitch: float = 1.0,
|
||||
) -> SynthesizeResult:
|
||||
"""语音合成(同步非流式).
|
||||
|
||||
@@ -695,6 +804,8 @@ class CosyVoiceService:
|
||||
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
|
||||
speed: 语速(0.5-2.0),1.0 为正常速度
|
||||
volume: 音量(0-100),默认 50
|
||||
style: 语气风格(natural/sweet/excited/professional/news/livestream)
|
||||
pitch: 音调(0.5-2.0),1.0 为默认值
|
||||
timeout: 超时时间(秒),保留参数兼容
|
||||
|
||||
Returns:
|
||||
@@ -714,6 +825,8 @@ class CosyVoiceService:
|
||||
volume=volume,
|
||||
emotion=emotion,
|
||||
language=language,
|
||||
style=style,
|
||||
pitch=pitch,
|
||||
)
|
||||
|
||||
return SynthesizeResult(
|
||||
|
||||
@@ -146,6 +146,9 @@ class TTSWorkflowService:
|
||||
_meta = dict(job.metadata)
|
||||
_speed = float(_meta.get("speed", 1.0) or 1.0)
|
||||
_emotion = str(_meta.get("emotion", "") or "")
|
||||
_style = str(_meta.get("style", "") or "")
|
||||
_volume = int(_meta.get("volume", 50) or 50)
|
||||
_pitch = float(_meta.get("pitch", 1.0) or 1.0)
|
||||
_language = str(_meta.get("language", "zh-CN") or "zh-CN")
|
||||
submit_result = self.cosyvoice_service.submit_synthesize_task(
|
||||
text=job.input_text,
|
||||
@@ -153,6 +156,9 @@ class TTSWorkflowService:
|
||||
sample_rate=job.sample_rate,
|
||||
format=job.format,
|
||||
speed=_speed,
|
||||
style=_style,
|
||||
volume=_volume,
|
||||
pitch=_pitch,
|
||||
emotion=_emotion,
|
||||
language=_language,
|
||||
)
|
||||
@@ -290,6 +296,8 @@ class TTSWorkflowService:
|
||||
job_metadata = job.metadata or {}
|
||||
speed = float(job_metadata.get("speed", 1.0))
|
||||
volume = int(job_metadata.get("volume", 50))
|
||||
style = str(job_metadata.get("style", "") or "")
|
||||
pitch = float(job_metadata.get("pitch", 1.0))
|
||||
emotion = str(job_metadata.get("emotion", "") or "")
|
||||
language = str(job_metadata.get("language", "zh-CN") or "zh-CN")
|
||||
|
||||
@@ -299,7 +307,9 @@ class TTSWorkflowService:
|
||||
sample_rate=job.sample_rate,
|
||||
format=job.format,
|
||||
speed=speed,
|
||||
style=style,
|
||||
volume=volume,
|
||||
pitch=pitch,
|
||||
emotion=emotion,
|
||||
language=language,
|
||||
)
|
||||
@@ -415,6 +425,9 @@ class TTSWorkflowService:
|
||||
_seg_meta = job.metadata or {}
|
||||
_seg_speed = float(_seg_meta.get("speed", 1.0) or 1.0)
|
||||
_seg_emotion = str(_seg_meta.get("emotion", "") or "")
|
||||
_seg_style = str(_seg_meta.get("style", "") or "")
|
||||
_seg_volume = int(_seg_meta.get("volume", 50) or 50)
|
||||
_seg_pitch = float(_seg_meta.get("pitch", 1.0) or 1.0)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_idx = {}
|
||||
@@ -426,6 +439,9 @@ class TTSWorkflowService:
|
||||
sample_rate=job.sample_rate,
|
||||
format=job.format,
|
||||
speed=_seg_speed,
|
||||
style=_seg_style,
|
||||
volume=_seg_volume,
|
||||
pitch=_seg_pitch,
|
||||
emotion=_seg_emotion,
|
||||
language=_seg_meta.get("language", "zh-CN") or "zh-CN",
|
||||
)
|
||||
@@ -517,6 +533,8 @@ class TTSWorkflowService:
|
||||
job_metadata = job.metadata or {}
|
||||
speed = float(job_metadata.get("speed", 1.0))
|
||||
volume = int(job_metadata.get("volume", 50))
|
||||
style = str(job_metadata.get("style", "") or "")
|
||||
pitch = float(job_metadata.get("pitch", 1.0))
|
||||
emotion = str(job_metadata.get("emotion", "") or "")
|
||||
|
||||
# 分段文本(用于缺失段重新合成)
|
||||
@@ -551,7 +569,9 @@ class TTSWorkflowService:
|
||||
sample_rate=job.sample_rate,
|
||||
format=job.format,
|
||||
speed=speed,
|
||||
style=style,
|
||||
volume=volume,
|
||||
pitch=pitch,
|
||||
emotion=emotion,
|
||||
language=job.metadata.get("language", "zh-CN") if hasattr(job, "metadata") else "zh-CN",
|
||||
)
|
||||
|
||||
+28
-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;
|
||||
|
||||
@@ -30,6 +30,8 @@ TITLE_MARGIN_SIDE = 40
|
||||
# - 楷体 → LXGW WenKai(霞鹜文楷,#1896 新增 SIL OFL 开源楷体)
|
||||
# - 苹方/PingFang/微软雅黑:服务器 Linux 无对应字体,fallback 思源黑体
|
||||
# - 华康俪金黑:商业字体有版权风险,前端已移除,后端保留映射 fallback 思源黑体(兼容老数据)
|
||||
# #2001 爆款标题字体(部署到 /usr/share/fonts/truetype/xiaoxia/):
|
||||
# - 优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy(独立 Black 字重)
|
||||
FONT_NAME_MAP: dict[str, str] = {
|
||||
"思源黑体": "Noto Sans SC",
|
||||
"思源宋体": "Noto Serif CJK SC",
|
||||
@@ -40,6 +42,15 @@ FONT_NAME_MAP: dict[str, str] = {
|
||||
"楷体": "LXGW WenKai",
|
||||
"霞鹜文楷": "LXGW WenKai",
|
||||
"华康俪金黑": "Noto Sans SC",
|
||||
# #2001 爆款标题字体
|
||||
"优设标题黑": "YouSheBiaoTiHei",
|
||||
"阿里普惠体": "Alibaba PuHuiTi",
|
||||
"阿里普惠体 Bold": "Alibaba PuHuiTi",
|
||||
"阿里巴巴普惠体": "Alibaba PuHuiTi",
|
||||
"抖音美好体": "Douyin Sans",
|
||||
"抖音体": "Douyin Sans",
|
||||
"思源黑体 Heavy": "Noto Sans SC",
|
||||
"思源黑体 Black": "Noto Sans SC",
|
||||
}
|
||||
|
||||
# ASS Fontsize 是字体 em-square 高度(含 Latin 升降部留白),
|
||||
@@ -421,6 +432,14 @@ def build_ass_content(
|
||||
safe_title_text_raw = escape_ass_text(title_text)
|
||||
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
|
||||
|
||||
# #2001 逐行样式覆盖:按 line_overrides 在每行前注入 ASS inline override 标签
|
||||
# line_overrides 透传自前端爆款标题面板,SubtitleStyle.from_dict 已做安全过滤
|
||||
if title_config.get("line_overrides"):
|
||||
from packages.domain.subtitle_style import SubtitleStyle
|
||||
|
||||
_title_style_for_overrides = SubtitleStyle.from_dict(title_config)
|
||||
safe_title_text = _title_style_for_overrides.apply_line_overrides(safe_title_text)
|
||||
|
||||
# 自由位置:在文本前注入 \pos override tag(锚点为文本块中心,配合 \an5)
|
||||
if title_pos is not None:
|
||||
safe_title_text = f"{{\\pos({title_pos[0]},{title_pos[1]})}}{safe_title_text}"
|
||||
@@ -455,6 +474,13 @@ def build_ass_content(
|
||||
|
||||
safe_subtitle_text = escape_ass_text(subtitle_text)
|
||||
|
||||
# #2001 逐行样式覆盖(字幕路径同样支持)
|
||||
if subtitle_config.get("line_overrides"):
|
||||
from packages.domain.subtitle_style import SubtitleStyle
|
||||
|
||||
_sub_style_for_overrides = SubtitleStyle.from_dict(subtitle_config)
|
||||
safe_subtitle_text = _sub_style_for_overrides.apply_line_overrides(safe_subtitle_text)
|
||||
|
||||
events.append(
|
||||
"Dialogue: 0,0:00:00.00,"
|
||||
f"{format_ass_time(video_duration)},"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
@@ -163,6 +163,12 @@ class SubtitleStyle:
|
||||
fade_out: float = 0.0
|
||||
animation_type: str = "none"
|
||||
|
||||
# 逐行独立样式覆盖(#2001 爆款标题样式面板)
|
||||
# list[dict],每项可选字段: line_index(0-based,支持负数从末尾倒数),
|
||||
# color/font/size/bold/italic/stroke_color/stroke_width/shadow_color/shadow_offset_x/shadow_offset_y
|
||||
# 渲染时按行索引匹配,用 ASS 内联 override 标签包裹该行。缺省字段继承主样式。
|
||||
line_overrides: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle":
|
||||
"""从字典创建样式配置,带安全类型转换."""
|
||||
@@ -193,6 +199,14 @@ class SubtitleStyle:
|
||||
if position not in POSITION_ALIGNMENT:
|
||||
position = DEFAULT_POSITION
|
||||
|
||||
# 逐行覆盖:仅保留 dict 类型项;非 dict 项过滤掉避免渲染崩溃
|
||||
raw_overrides = config.get("line_overrides") or []
|
||||
line_overrides: list[dict[str, Any]] = []
|
||||
if isinstance(raw_overrides, list):
|
||||
for item in raw_overrides:
|
||||
if isinstance(item, dict):
|
||||
line_overrides.append(dict(item))
|
||||
|
||||
return cls(
|
||||
font_name=safe_str("font", DEFAULT_FONT),
|
||||
font_size=safe_int("size", DEFAULT_FONT_SIZE),
|
||||
@@ -221,6 +235,7 @@ class SubtitleStyle:
|
||||
fade_in=max(0.0, safe_float("fade_in", 0.0)),
|
||||
fade_out=max(0.0, safe_float("fade_out", 0.0)),
|
||||
animation_type=safe_str("animation_type", "none"),
|
||||
line_overrides=line_overrides,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -248,6 +263,130 @@ class SubtitleStyle:
|
||||
color_bgr = hex_to_ass_bgr(self.background_color)
|
||||
return f"&H{alpha_hex}{color_bgr}"
|
||||
|
||||
def build_line_override_tag(self, line_index: int, total_lines: int) -> str:
|
||||
r"""按 line_overrides 配置为指定行构造 ASS 内联 override 标签 {\c&HBBGGRR&...}.
|
||||
|
||||
仅返回大括号包裹的 override 标签串;调用方拼到该行文本前即可。
|
||||
未配置该覆盖项时返回空串。缺省字段继承主样式,不生成对应 tag。
|
||||
|
||||
Args:
|
||||
line_index: 行号(0-based);支持负数(-1 为最后一行)。
|
||||
total_lines: 总行数(用于解析负数索引)。
|
||||
"""
|
||||
if not self.line_overrides or total_lines <= 0:
|
||||
return ""
|
||||
|
||||
# 解析负数索引
|
||||
resolved = line_index if line_index >= 0 else total_lines + line_index
|
||||
if resolved < 0 or resolved >= total_lines:
|
||||
return ""
|
||||
|
||||
override: dict[str, Any] | None = None
|
||||
for item in self.line_overrides:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
idx = item.get("line_index")
|
||||
try:
|
||||
idx_int = int(idx) if idx is not None else None
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if idx_int is None:
|
||||
continue
|
||||
if idx_int < 0:
|
||||
idx_int = total_lines + idx_int
|
||||
if idx_int == resolved:
|
||||
override = item
|
||||
break
|
||||
|
||||
if not override:
|
||||
return ""
|
||||
|
||||
tags: list[str] = []
|
||||
|
||||
# 主色(字体颜色):\c&HBBGGRR&
|
||||
color_val = override.get("color")
|
||||
if isinstance(color_val, str) and color_val:
|
||||
tags.append(f"\\c{hex_to_ass_color(color_val)}")
|
||||
|
||||
# 字号:\fsN
|
||||
size_val = override.get("size") or override.get("font_size")
|
||||
try:
|
||||
size_int = int(size_val) if size_val is not None else None
|
||||
if size_int and size_int > 0:
|
||||
tags.append(f"\\fs{size_int}")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 字体:\fnFontName
|
||||
font_val = override.get("font") or override.get("font_name")
|
||||
if isinstance(font_val, str) and font_val:
|
||||
tags.append(f"\\fn{font_val}")
|
||||
|
||||
# 粗体:\b1 / \b0
|
||||
bold_val = override.get("bold")
|
||||
if isinstance(bold_val, bool):
|
||||
tags.append("\\b1" if bold_val else "\\b0")
|
||||
|
||||
# 斜体:\i1 / \i0
|
||||
italic_val = override.get("italic")
|
||||
if isinstance(italic_val, bool):
|
||||
tags.append("\\i1" if italic_val else "\\i0")
|
||||
|
||||
# 描边色:\3c&HBBGGRR&
|
||||
stroke_c = override.get("stroke_color")
|
||||
if isinstance(stroke_c, str) and stroke_c:
|
||||
tags.append(f"\\3c{hex_to_ass_color(stroke_c)}")
|
||||
|
||||
# 描边宽:\bordN
|
||||
stroke_w = override.get("stroke_width")
|
||||
try:
|
||||
sw = float(stroke_w) if stroke_w is not None else None
|
||||
if sw is not None and sw >= 0:
|
||||
tags.append(f"\\bord{sw:g}")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 阴影色:\4c&HBBGGRR&
|
||||
shadow_c = override.get("shadow_color")
|
||||
if isinstance(shadow_c, str) and shadow_c:
|
||||
tags.append(f"\\4c{hex_to_ass_color(shadow_c)}")
|
||||
|
||||
# 阴影偏移:\shadN(单值,同时设置 x/y;精细控制用 \xshad/\yshad)
|
||||
sx = override.get("shadow_offset_x")
|
||||
sy = override.get("shadow_offset_y")
|
||||
try:
|
||||
sx_i = int(sx) if sx is not None else None
|
||||
sy_i = int(sy) if sy is not None else None
|
||||
if sx_i is not None:
|
||||
tags.append(f"\\xshad{sx_i}")
|
||||
if sy_i is not None:
|
||||
tags.append(f"\\yshad{sy_i}")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if not tags:
|
||||
return ""
|
||||
return "{" + "".join(tags) + "}"
|
||||
|
||||
def apply_line_overrides(self, text: str) -> str:
|
||||
"""按 line_overrides 对 ASS 文本(已 escape、换行用 \\N 分隔)逐行套 override 标签.
|
||||
|
||||
仅对换行后的每行首加对应 override 标签;无 override 的行保持原样。
|
||||
"""
|
||||
if not self.line_overrides or not text:
|
||||
return text
|
||||
if "\\N" not in text:
|
||||
# 单行
|
||||
tag = self.build_line_override_tag(0, 1)
|
||||
return tag + text if tag else text
|
||||
lines = text.split("\\N")
|
||||
total = len(lines)
|
||||
out: list[str] = []
|
||||
for i, ln in enumerate(lines):
|
||||
tag = self.build_line_override_tag(i, total)
|
||||
out.append(tag + ln if tag else ln)
|
||||
return "\\N".join(out)
|
||||
|
||||
|
||||
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -388,6 +388,11 @@ DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [
|
||||
"/usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf",
|
||||
"/usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf",
|
||||
# #2001 爆款标题字体(优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy)
|
||||
"/usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf",
|
||||
"/usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf",
|
||||
"/usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
@@ -400,6 +405,7 @@ DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [
|
||||
# #1896 字体映射修复:每个字体映射到独立的关键字,而非全部回退到 NotoSansSC
|
||||
# - 苹方(macOS)/ 微软雅黑(Windows)/ PingFang:服务器 Linux 无对应文件,fallback 思源黑体
|
||||
# - 华康俪金黑:商业字体有版权风险,前端已按 #1896 要求移除,后端保留映射但 fallback 思源黑体(兼容老数据)
|
||||
# #2001 新增爆款标题字体映射
|
||||
DRAWTEXT_FONT_MAP: dict[str, str] = {
|
||||
"思源黑体": "NotoSansSC",
|
||||
"思源宋体": "NotoSerifCJKsc",
|
||||
@@ -410,6 +416,15 @@ DRAWTEXT_FONT_MAP: dict[str, str] = {
|
||||
"楷体": "LXGWWenKai",
|
||||
"霞鹜文楷": "LXGWWenKai",
|
||||
"华康俪金黑": "NotoSansSC",
|
||||
# #2001 爆款标题字体
|
||||
"优设标题黑": "YouSheBiaoTiHei",
|
||||
"阿里普惠体": "AlibabaPuHuiTi-Bold",
|
||||
"阿里普惠体 Bold": "AlibabaPuHuiTi-Bold",
|
||||
"阿里巴巴普惠体": "AlibabaPuHuiTi-Bold",
|
||||
"抖音美好体": "DouyinSansBold",
|
||||
"抖音体": "DouyinSansBold",
|
||||
"思源黑体 Heavy": "NotoSansSC-Black",
|
||||
"思源黑体 Black": "NotoSansSC-Black",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 不是驱动音频"
|
||||
@@ -6,6 +6,7 @@ CI 增量映射:
|
||||
ai_avatar_cover_service 智能选帧
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -166,6 +167,116 @@ def test_submit_synthesize_payload_cloned_voice_english_emotion():
|
||||
assert inp["instruction"] == "Speak in a sad tone."
|
||||
|
||||
|
||||
# ── style(语气风格,#2002)────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_style_natural_omits_instruction():
|
||||
"""style=natural 不加 instruct,使用 CosyVoice 默认自然语气。"""
|
||||
captured: dict = {}
|
||||
svc = _make_service_with_captured_client(captured)
|
||||
|
||||
svc.submit_synthesize_task(text="你好", voice_id="myclone_voice", style="natural")
|
||||
|
||||
inp = captured["json"]["input"]
|
||||
assert "instruction" not in inp
|
||||
|
||||
|
||||
def test_style_sweet_cloned_voice_uses_chinese_instruction():
|
||||
"""克隆音色 + style=sweet → 中文自然语言指令。"""
|
||||
captured: dict = {}
|
||||
svc = _make_service_with_captured_client(captured)
|
||||
|
||||
svc.submit_synthesize_task(text="你好", voice_id="myclone_voice", style="sweet")
|
||||
|
||||
inp = captured["json"]["input"]
|
||||
assert inp["instruction"] == "用温柔甜美、亲切柔和的语气说话。"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("style", "expected_fragment"),
|
||||
[
|
||||
("excited", "兴奋"),
|
||||
("professional", "专业"),
|
||||
("news", "新闻"),
|
||||
("livestream", "直播"),
|
||||
],
|
||||
)
|
||||
def test_style_values_cloned_voice(style, expected_fragment):
|
||||
captured: dict = {}
|
||||
svc = _make_service_with_captured_client(captured)
|
||||
|
||||
svc.submit_synthesize_task(text="你好", voice_id="myclone_voice", style=style)
|
||||
|
||||
inp = captured["json"]["input"]
|
||||
assert expected_fragment in inp["instruction"]
|
||||
|
||||
|
||||
def test_style_system_voice_uses_emotion_mapping():
|
||||
"""系统白名单音色 + style=sweet → 严格中文 emotion 格式(映射到 sad)。"""
|
||||
captured: dict = {}
|
||||
svc = _make_service_with_captured_client(captured)
|
||||
|
||||
svc.submit_synthesize_task(text="你好", voice_id="longanyang", style="sweet")
|
||||
|
||||
inp = captured["json"]["input"]
|
||||
assert inp["instruction"] == "你说话的情感是sad。"
|
||||
|
||||
|
||||
def test_style_takes_priority_over_emotion():
|
||||
"""同时传 style 和 emotion 时以 style 为准。"""
|
||||
captured: dict = {}
|
||||
svc = _make_service_with_captured_client(captured)
|
||||
|
||||
svc.submit_synthesize_task(text="你好", voice_id="myclone_voice", style="excited", emotion="sad")
|
||||
|
||||
inp = captured["json"]["input"]
|
||||
assert "兴奋" in inp["instruction"]
|
||||
|
||||
|
||||
def test_unknown_style_ignored_falls_back_to_emotion():
|
||||
"""未知 style 值被忽略,回退到 emotion 逻辑。"""
|
||||
captured: dict = {}
|
||||
svc = _make_service_with_captured_client(captured)
|
||||
|
||||
svc.submit_synthesize_task(text="hi", voice_id="myclone_voice", style="nonexistent", emotion="sad")
|
||||
|
||||
inp = captured["json"]["input"]
|
||||
assert inp["instruction"] == "Speak in a sad tone."
|
||||
|
||||
|
||||
def test_pitch_passed_only_when_non_default():
|
||||
captured: dict = {}
|
||||
svc = _make_service_with_captured_client(captured)
|
||||
|
||||
svc.submit_synthesize_task(text="hi", voice_id="myclone_voice", pitch=1.5)
|
||||
|
||||
inp = captured["json"]["input"]
|
||||
assert inp["pitch"] == 1.5
|
||||
|
||||
|
||||
def test_pitch_omitted_at_default():
|
||||
captured: dict = {}
|
||||
svc = _make_service_with_captured_client(captured)
|
||||
|
||||
svc.submit_synthesize_task(text="hi", voice_id="myclone_voice", pitch=1.0)
|
||||
|
||||
inp = captured["json"]["input"]
|
||||
assert "pitch" not in inp
|
||||
|
||||
|
||||
def test_resolve_style_maps_emotion_to_style():
|
||||
"""旧 emotion 值通过 resolve_style 映射为 style。"""
|
||||
mod = importlib.import_module("packages.application.cosyvoice_service")
|
||||
|
||||
assert mod.resolve_style(emotion="happy") == "excited"
|
||||
assert mod.resolve_style(emotion="sad") == "sweet"
|
||||
assert mod.resolve_style(emotion="neutral") == "natural"
|
||||
assert mod.resolve_style(style="news") == "news"
|
||||
# style 优先
|
||||
assert mod.resolve_style(style="news", emotion="happy") == "news"
|
||||
assert mod.resolve_style() == ""
|
||||
|
||||
|
||||
# ── 对口型 TTS 直生分支 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ def _make_mock_render_job(
|
||||
m.b_roll_segments = []
|
||||
m.title_config = {}
|
||||
m.cover_config = {}
|
||||
m.cover_title_config = {}
|
||||
m.status = status
|
||||
m.progress = progress
|
||||
m.output_video_url = output_video_url
|
||||
@@ -93,6 +94,7 @@ class TestRenderRoutes:
|
||||
body.b_roll_segments = []
|
||||
body.title_config = {}
|
||||
body.cover_config = {}
|
||||
body.cover_title_config = {}
|
||||
body.project_id = ""
|
||||
|
||||
result = create_render_job(
|
||||
@@ -118,6 +120,7 @@ class TestRenderRoutes:
|
||||
body.b_roll_segments = []
|
||||
body.title_config = {}
|
||||
body.cover_config = {}
|
||||
body.cover_title_config = {}
|
||||
body.project_id = ""
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -141,6 +144,7 @@ class TestRenderRoutes:
|
||||
body.b_roll_segments = []
|
||||
body.title_config = {}
|
||||
body.cover_config = {}
|
||||
body.cover_title_config = {}
|
||||
body.project_id = ""
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
|
||||
@@ -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,250 @@
|
||||
"""Celery 任务 lipsync_gpu_process_async 直接单测 (#1978 异步化).
|
||||
|
||||
覆盖 apps/api/app/tasks/lipsync_gpu.py 的全部主路径:
|
||||
- 成功:wait_for_result 返回 done → 签名 URL → completed
|
||||
- GPU 超时/失败 → MediaKit 兜底(成功/MediaKitError/其他异常)
|
||||
- job 不存在 / 状态异常提前返回
|
||||
- 主流程异常 → job 标 failed
|
||||
- _sign_media_url 各分支
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import app.tasks.lipsync_gpu as task_mod
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_job(status="processing"):
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
job.user_id = "u1"
|
||||
job.status = status
|
||||
job.video_url = "videos/v.mp4"
|
||||
job.audio_url = "audios/a.wav"
|
||||
job.enable_video_loop = True
|
||||
return job
|
||||
|
||||
|
||||
def _make_gpu_task(status="done", result_url="gpu-lipsync/results/t1.mp4", result_duration=11.2):
|
||||
t = MagicMock()
|
||||
t.status = status
|
||||
t.result_url = result_url
|
||||
t.result_duration = result_duration
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_patch():
|
||||
"""patch _get_db_session 返回 MagicMock,并在任务结束后断言 close."""
|
||||
fake_db = MagicMock()
|
||||
with patch.object(task_mod, "_get_db_session", return_value=fake_db):
|
||||
yield fake_db
|
||||
|
||||
|
||||
def _patch_gpu_service(final_task):
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.wait_for_result.return_value = final_task
|
||||
return patch(
|
||||
"app.services.gpu_lipsync_service.GpuLipsyncService",
|
||||
return_value=fake_svc,
|
||||
)
|
||||
|
||||
|
||||
def _run_task():
|
||||
# @shared_task bind=True:直接调用任务对象会自动注入 self
|
||||
task_mod.lipsync_gpu_process_async("job-1", "u1", "gpu-task-1")
|
||||
|
||||
|
||||
class TestHappyPath:
|
||||
def test_gpu_done_marks_completed(self, db_patch):
|
||||
job = _make_job()
|
||||
db_patch.query.return_value.filter_by.return_value.first.return_value = job
|
||||
gpu_task = _make_gpu_task()
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.return_value = "https://signed.example.com/r1.mp4?sig=x"
|
||||
with (
|
||||
_patch_gpu_service(gpu_task),
|
||||
patch.object(task_mod, "get_shared_storage_service", return_value=storage),
|
||||
):
|
||||
_run_task()
|
||||
assert job.status == "completed"
|
||||
assert job.output_video_url == "https://signed.example.com/r1.mp4?sig=x"
|
||||
assert job.output_duration == 11.2
|
||||
assert job.completed_at is not None
|
||||
db_patch.commit.assert_called_once()
|
||||
db_patch.close.assert_called_once()
|
||||
|
||||
def test_gpu_done_empty_signed_url_keeps_original(self, db_patch):
|
||||
job = _make_job()
|
||||
db_patch.query.return_value.filter_by.return_value.first.return_value = job
|
||||
gpu_task = _make_gpu_task(result_url="gpu/r2.mp4")
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.return_value = ""
|
||||
with (
|
||||
_patch_gpu_service(gpu_task),
|
||||
patch.object(task_mod, "get_shared_storage_service", return_value=storage),
|
||||
):
|
||||
_run_task()
|
||||
assert job.status == "completed"
|
||||
assert job.output_video_url == "gpu/r2.mp4"
|
||||
|
||||
def test_gpu_done_result_duration_none_defaults_zero(self, db_patch):
|
||||
job = _make_job()
|
||||
db_patch.query.return_value.filter_by.return_value.first.return_value = job
|
||||
gpu_task = _make_gpu_task(result_duration=None)
|
||||
storage = MagicMock()
|
||||
with (
|
||||
_patch_gpu_service(gpu_task),
|
||||
patch.object(task_mod, "get_shared_storage_service", return_value=storage),
|
||||
):
|
||||
_run_task()
|
||||
assert job.output_duration == 0.0
|
||||
|
||||
def test_sign_failure_uses_original_url(self, db_patch):
|
||||
job = _make_job()
|
||||
db_patch.query.return_value.filter_by.return_value.first.return_value = job
|
||||
gpu_task = _make_gpu_task(result_url="gpu/r3.mp4")
|
||||
with (
|
||||
_patch_gpu_service(gpu_task),
|
||||
patch.object(task_mod, "get_shared_storage_service", side_effect=RuntimeError("oss down")),
|
||||
):
|
||||
_run_task()
|
||||
assert job.status == "completed"
|
||||
assert job.output_video_url == "gpu/r3.mp4"
|
||||
|
||||
|
||||
class TestJobGuards:
|
||||
def test_job_not_found_returns(self, db_patch):
|
||||
db_patch.query.return_value.filter_by.return_value.first.return_value = None
|
||||
_run_task()
|
||||
db_patch.commit.assert_not_called()
|
||||
db_patch.close.assert_called_once()
|
||||
|
||||
def test_job_wrong_status_skipped(self, db_patch):
|
||||
job = _make_job(status="completed")
|
||||
db_patch.query.return_value.filter_by.return_value.first.return_value = job
|
||||
_run_task()
|
||||
db_patch.commit.assert_not_called()
|
||||
|
||||
|
||||
class TestGpuFailureFallback:
|
||||
def test_gpu_timeout_falls_back_mediakit_success(self, db_patch):
|
||||
job = _make_job()
|
||||
db_patch.query.return_value.filter_by.return_value.first.return_value = job
|
||||
with (
|
||||
_patch_gpu_service(None),
|
||||
patch.object(task_mod, "_fallback_to_mediakit") as fb,
|
||||
):
|
||||
_run_task()
|
||||
fb.assert_called_once_with(db_patch, job)
|
||||
|
||||
def test_gpu_failed_status_falls_back(self, db_patch):
|
||||
job = _make_job()
|
||||
db_patch.query.return_value.filter_by.return_value.first.return_value = job
|
||||
gpu_task = _make_gpu_task(status="failed")
|
||||
with (
|
||||
_patch_gpu_service(gpu_task),
|
||||
patch.object(task_mod, "_fallback_to_mediakit") as fb,
|
||||
):
|
||||
_run_task()
|
||||
fb.assert_called_once_with(db_patch, job)
|
||||
|
||||
|
||||
class TestFallbackToMediaKit:
|
||||
def test_mediakit_success_marks_submitted(self, db_patch):
|
||||
job = _make_job()
|
||||
fake_client = MagicMock()
|
||||
fake_client.submit_lipsync.return_value = {"task_id": "mk-99"}
|
||||
with (
|
||||
patch("app.services.mediakit_client.get_mediakit_client", return_value=fake_client),
|
||||
patch.object(task_mod, "_sign_media_url", side_effect=lambda u: u + "?s"),
|
||||
):
|
||||
task_mod._fallback_to_mediakit(db_patch, job)
|
||||
fake_client.submit_lipsync.assert_called_once()
|
||||
kwargs = fake_client.submit_lipsync.call_args.kwargs
|
||||
assert kwargs["enable_video_loop"] is True
|
||||
assert kwargs["client_token"] == "job-1"
|
||||
assert job.status == "submitted"
|
||||
assert job.mediakit_task_id == "mk-99"
|
||||
db_patch.commit.assert_called_once()
|
||||
|
||||
def test_mediakit_error_marks_failed(self, db_patch):
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
|
||||
job = _make_job()
|
||||
fake_client = MagicMock()
|
||||
fake_client.submit_lipsync.side_effect = MediaKitError("api reject", code="MkReject")
|
||||
with (
|
||||
patch("app.services.mediakit_client.get_mediakit_client", return_value=fake_client),
|
||||
patch.object(task_mod, "_sign_media_url", side_effect=lambda u: u),
|
||||
):
|
||||
task_mod._fallback_to_mediakit(db_patch, job)
|
||||
assert job.status == "failed"
|
||||
assert job.error_code == "MkReject"
|
||||
db_patch.commit.assert_called_once()
|
||||
|
||||
def test_other_exception_marks_failed(self, db_patch):
|
||||
job = _make_job()
|
||||
with (
|
||||
patch("app.services.mediakit_client.get_mediakit_client", side_effect=RuntimeError("boom")),
|
||||
patch.object(task_mod, "_sign_media_url", side_effect=lambda u: u),
|
||||
):
|
||||
task_mod._fallback_to_mediakit(db_patch, job)
|
||||
assert job.status == "failed"
|
||||
assert job.error_code == "FallbackFailed"
|
||||
db_patch.commit.assert_called_once()
|
||||
|
||||
|
||||
class TestTaskException:
|
||||
def test_unexpected_exception_marks_job_failed(self, db_patch):
|
||||
job = _make_job()
|
||||
# query 第一次返回 job,异常路径里再次 query 也返回 job
|
||||
db_patch.query.return_value.filter_by.return_value.first.return_value = job
|
||||
with patch(
|
||||
"app.services.gpu_lipsync_service.GpuLipsyncService",
|
||||
side_effect=RuntimeError("svc ctor fail"),
|
||||
):
|
||||
_run_task()
|
||||
assert job.status == "failed"
|
||||
assert job.error_code == "GpuAsyncError"
|
||||
|
||||
def test_exception_handler_failure_swallowed(self, db_patch):
|
||||
# 主流程异常,且异常处理中的 query 也抛异常 → 不应再抛
|
||||
db_patch.query.side_effect = RuntimeError("db totally broken")
|
||||
_run_task()
|
||||
db_patch.close.assert_called_once()
|
||||
|
||||
|
||||
class TestSignMediaUrl:
|
||||
def test_empty_url_returned_as_is(self):
|
||||
assert task_mod._sign_media_url("") == ""
|
||||
|
||||
def test_non_own_host_returned_as_is(self):
|
||||
storage = MagicMock()
|
||||
storage.public_url = "https://own-bucket.oss-cn-beijing.aliyuncs.com"
|
||||
with patch.object(task_mod, "get_shared_storage_service", return_value=storage):
|
||||
url = "https://other.example.com/a.wav"
|
||||
assert task_mod._sign_media_url(url) == url
|
||||
|
||||
def test_own_host_signed(self):
|
||||
storage = MagicMock()
|
||||
storage.public_url = "https://own-bucket.oss-cn-beijing.aliyuncs.com"
|
||||
storage.get_download_url.return_value = "https://own-bucket.oss-cn-beijing.aliyuncs.com/a?sig=1"
|
||||
with patch.object(task_mod, "get_shared_storage_service", return_value=storage):
|
||||
out = task_mod._sign_media_url("https://own-bucket.oss-cn-beijing.aliyuncs.com/a.wav")
|
||||
assert out.endswith("?sig=1")
|
||||
storage.get_download_url.assert_called_once()
|
||||
|
||||
def test_missing_public_url_returns_original(self):
|
||||
storage = MagicMock()
|
||||
storage.public_url = ""
|
||||
with patch.object(task_mod, "get_shared_storage_service", return_value=storage):
|
||||
url = "https://own-bucket.oss-cn-beijing.aliyuncs.com/a.wav"
|
||||
assert task_mod._sign_media_url(url) == url
|
||||
|
||||
def test_exception_returns_original(self):
|
||||
with patch.object(task_mod, "get_shared_storage_service", side_effect=RuntimeError("x")):
|
||||
url = "https://own-bucket.oss-cn-beijing.aliyuncs.com/a.wav"
|
||||
assert task_mod._sign_media_url(url) == url
|
||||
@@ -1,4 +1,10 @@
|
||||
"""LipsyncService GPU 路径集成测试."""
|
||||
"""LipsyncService GPU 路径集成测试 (#1978 异步版本).
|
||||
|
||||
#1978 性能修复:GPU 推理从同步等待改为异步。
|
||||
- _submit_audio_direct 创建 GPU 任务后立即返回,job.status="processing"
|
||||
- Celery 任务 lipsync_gpu_process_async 负责等待结果+回写
|
||||
- 本测试验证:创建任务、异步派发、音频转存等逻辑
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,7 +26,7 @@ def fake_mediakit():
|
||||
return client
|
||||
|
||||
|
||||
def _make_job(video_url="oss://video.mp4", audio_url="oss://audio.wav"):
|
||||
def _make_job(video_url="videos/video.mp4", audio_url="audios/audio.wav"):
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
job.user_id = "u1"
|
||||
@@ -42,12 +48,20 @@ def _make_svc(db, mediakit, use_gpu=False):
|
||||
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."""
|
||||
"""开关关闭时直接走 MediaKit,不创建 GPU 任务."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=False)
|
||||
job = _make_job()
|
||||
with patch.object(svc, "_submit_to_gpu") as m_sub:
|
||||
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()
|
||||
@@ -65,67 +79,225 @@ class TestGpuFallback:
|
||||
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."""
|
||||
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="oss://gpu-results/r.mp4",
|
||||
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("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
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.create_task.assert_called_once()
|
||||
fake_mediakit.submit_lipsync.assert_not_called()
|
||||
# 降级同步等待完成
|
||||
fake_gpu_svc.wait_for_result.assert_called_once()
|
||||
assert job.status == "completed"
|
||||
assert job.output_duration == 12.5
|
||||
assert "?signed" in job.output_video_url
|
||||
fake_db.commit.assert_called()
|
||||
storage.get_download_url.assert_called_once_with(
|
||||
"gpu-lipsync/results/gpu-task-1.mp4", expires_seconds=7 * 24 * 3600
|
||||
)
|
||||
|
||||
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."""
|
||||
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("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
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_create_returns_none_falls_back_mediakit(self, fake_db, fake_mediakit):
|
||||
"""_submit_to_gpu_create 返回 None(create_task 失败被内部吞掉)→ rollback + MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
with (
|
||||
_patch_storage(),
|
||||
patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc),
|
||||
patch.object(svc, "_submit_to_gpu_create", return_value=None) as m_create,
|
||||
):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
m_create.assert_called_once()
|
||||
fake_db.rollback.assert_called_once()
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_submit_to_gpu_wait_timeout_returns(self, fake_db, fake_mediakit):
|
||||
"""降级同步等待:wait_for_result 返回 None → 直接返回,job 保持 processing."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.wait_for_result.return_value = None
|
||||
job = _make_job()
|
||||
job.status = "processing"
|
||||
svc._submit_to_gpu_wait(job=job, gpu_svc=fake_gpu_svc, gpu_task=MagicMock(id="gpu-task-x"))
|
||||
fake_gpu_svc.wait_for_result.assert_called_once_with("gpu-task-x")
|
||||
fake_db.commit.assert_not_called()
|
||||
assert job.status == "processing"
|
||||
|
||||
def test_submit_to_gpu_wait_failed_status_returns(self, fake_db, fake_mediakit):
|
||||
"""降级同步等待:final_task.status != done → 直接返回."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.wait_for_result.return_value = MagicMock(status="failed", result_url="")
|
||||
job = _make_job()
|
||||
job.status = "processing"
|
||||
svc._submit_to_gpu_wait(job=job, gpu_svc=fake_gpu_svc, gpu_task=MagicMock(id="gpu-task-y"))
|
||||
fake_db.commit.assert_not_called()
|
||||
assert job.status == "processing"
|
||||
|
||||
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 TestRefreshGpuStale:
|
||||
"""refresh_job_status 的 GPU 异步 stale 超时分支."""
|
||||
|
||||
def test_stale_gpu_job_marked_failed(self, fake_db):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
svc = _make_svc(fake_db, MagicMock(), use_gpu=True)
|
||||
job = MagicMock()
|
||||
job.status = "processing"
|
||||
job.mediakit_task_id = "gpu:gpu-task-stale"
|
||||
job.updated_at = datetime.now(UTC) - timedelta(minutes=31)
|
||||
with patch.object(svc, "get_job", return_value=job):
|
||||
result = svc.refresh_job_status("job-stale", "u1")
|
||||
assert result is job
|
||||
assert job.status == "failed"
|
||||
assert job.error_code == "GpuTimeout"
|
||||
fake_db.commit.assert_called_once()
|
||||
|
||||
def test_fresh_gpu_job_left_processing(self, fake_db):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
svc = _make_svc(fake_db, MagicMock(), use_gpu=True)
|
||||
job = MagicMock()
|
||||
job.status = "processing"
|
||||
job.mediakit_task_id = "gpu:gpu-task-fresh"
|
||||
job.updated_at = datetime.now(UTC) - timedelta(minutes=2)
|
||||
with patch.object(svc, "get_job", return_value=job):
|
||||
result = svc.refresh_job_status("job-fresh", "u1")
|
||||
assert result is job
|
||||
assert job.status == "processing"
|
||||
fake_db.commit.assert_not_called()
|
||||
|
||||
def test_naive_updated_at_stale_marked_failed(self, fake_db):
|
||||
"""updated_at 为 naive datetime 时按 UTC 补时区后再判定."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
svc = _make_svc(fake_db, MagicMock(), use_gpu=True)
|
||||
job = MagicMock()
|
||||
job.status = "gpu_processing"
|
||||
job.mediakit_task_id = "gpu:gpu-task-naive"
|
||||
job.updated_at = datetime.now(UTC).replace(tzinfo=None) - timedelta(minutes=31)
|
||||
with patch.object(svc, "get_job", return_value=job):
|
||||
svc.refresh_job_status("job-naive", "u1")
|
||||
assert job.status == "failed"
|
||||
assert job.error_code == "GpuTimeout"
|
||||
|
||||
|
||||
class TestGpuServiceHelpers:
|
||||
"""GpuLipsyncService.has_available_worker 测试."""
|
||||
@@ -142,7 +314,6 @@ class TestGpuServiceHelpers:
|
||||
|
||||
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
|
||||
|
||||
@@ -150,6 +321,5 @@ class TestGpuServiceHelpers:
|
||||
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
|
||||
|
||||
@@ -298,6 +298,7 @@ class TestLipsyncServiceUnit:
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
||||
svc.settings.use_gpu_lipsync = False
|
||||
|
||||
with pytest.raises(MediaKitError, match="API 调用失败"):
|
||||
svc.create_job(
|
||||
@@ -473,6 +474,7 @@ class TestLipsyncServiceUnit:
|
||||
cosyvoice_service=mock_cosyvoice,
|
||||
voice_clone_repo=MagicMock(),
|
||||
)
|
||||
svc.settings.use_gpu_lipsync = False
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
@@ -616,12 +618,14 @@ class TestSignMediaUrl403Fix:
|
||||
def _svc(self, mock_mediakit, mock_cosyvoice):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
return LipsyncService(
|
||||
svc = LipsyncService(
|
||||
MagicMock(),
|
||||
client=mock_mediakit,
|
||||
cosyvoice_service=mock_cosyvoice,
|
||||
voice_clone_repo=MagicMock(),
|
||||
)
|
||||
svc.settings.use_gpu_lipsync = False
|
||||
return svc
|
||||
|
||||
def test_own_oss_unsigned_url_gets_resigned(self, mock_mediakit, mock_cosyvoice):
|
||||
"""裸 public_url(不带签名,私有桶匿名 403)必须被重签."""
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -399,3 +399,226 @@ class TestSubtitleSegment:
|
||||
def test_is_valid_zero_duration(self):
|
||||
seg = SubtitleSegment(start=1, end=1, text="hello")
|
||||
assert seg.is_valid is False
|
||||
|
||||
|
||||
# ── line_overrides 逐行样式覆盖测试 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildLineOverrideTag:
|
||||
def test_empty_overrides_returns_empty(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.build_line_override_tag(0, 1) == ""
|
||||
|
||||
def test_no_matching_line_returns_empty(self):
|
||||
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 1, "color": "#FF0000"}]})
|
||||
assert style.build_line_override_tag(0, 2) == ""
|
||||
|
||||
def test_out_of_range_index_returns_empty(self):
|
||||
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 5, "color": "#FF0000"}]})
|
||||
assert style.build_line_override_tag(0, 2) == ""
|
||||
|
||||
def test_negative_index_resolves_from_end(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"line_overrides": [{"line_index": -1, "color": "#FF0000", "font_size": 48}],
|
||||
}
|
||||
)
|
||||
tag = style.build_line_override_tag(-1, 3) # last line, 3 lines total
|
||||
assert tag
|
||||
assert "\\c&H000000FF" in tag # red
|
||||
assert "\\fs48" in tag
|
||||
assert tag.startswith("{") and tag.endswith("}")
|
||||
|
||||
def test_color_override(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"line_overrides": [{"line_index": 0, "color": "#00FF00"}],
|
||||
}
|
||||
)
|
||||
tag = style.build_line_override_tag(0, 1)
|
||||
assert "\\c&H0000FF00" in tag # green = 00FF00 → bgr 00FF00
|
||||
|
||||
def test_font_size_override(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"line_overrides": [{"line_index": 0, "font_size": 60}],
|
||||
}
|
||||
)
|
||||
tag = style.build_line_override_tag(0, 1)
|
||||
assert "\\fs60" in tag
|
||||
|
||||
def test_font_override(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"line_overrides": [{"line_index": 0, "font": "优设标题黑"}],
|
||||
}
|
||||
)
|
||||
tag = style.build_line_override_tag(0, 1)
|
||||
assert "\\fn优设标题黑" in tag
|
||||
|
||||
def test_bold_on_off(self):
|
||||
style_bold = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "bold": True}]})
|
||||
assert "\\b1" in style_bold.build_line_override_tag(0, 1)
|
||||
style_nobold = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "bold": False}]})
|
||||
assert "\\b0" in style_nobold.build_line_override_tag(0, 1)
|
||||
|
||||
def test_italic_on_off(self):
|
||||
style_italic = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "italic": True}]})
|
||||
assert "\\i1" in style_italic.build_line_override_tag(0, 1)
|
||||
style_noitalic = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "italic": False}]})
|
||||
assert "\\i0" in style_noitalic.build_line_override_tag(0, 1)
|
||||
|
||||
def test_stroke_override(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"line_overrides": [{"line_index": 0, "stroke_color": "#0000FF", "stroke_width": 3.0}],
|
||||
}
|
||||
)
|
||||
tag = style.build_line_override_tag(0, 1)
|
||||
assert "\\3c&H00FF0000" in tag # blue bgr
|
||||
assert "\\bord3" in tag
|
||||
|
||||
def test_shadow_override(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"line_overrides": [
|
||||
{"line_index": 0, "shadow_color": "#000000", "shadow_offset_x": 2, "shadow_offset_y": 3}
|
||||
],
|
||||
}
|
||||
)
|
||||
tag = style.build_line_override_tag(0, 1)
|
||||
assert "\\4c&H00000000" in tag # black
|
||||
assert "\\xshad2" in tag
|
||||
assert "\\yshad3" in tag
|
||||
|
||||
def test_full_combo(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"line_overrides": [
|
||||
{
|
||||
"line_index": 0,
|
||||
"color": "#FF0000",
|
||||
"font_size": 72,
|
||||
"font": "抖音美好体",
|
||||
"bold": True,
|
||||
"italic": False,
|
||||
"stroke_color": "#FFFFFF",
|
||||
"stroke_width": 2,
|
||||
"shadow_color": "#000000",
|
||||
"shadow_offset_x": 0,
|
||||
"shadow_offset_y": 4,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
tag = style.build_line_override_tag(0, 1)
|
||||
assert "\\c&H000000FF" in tag
|
||||
assert "\\fs72" in tag
|
||||
assert "\\fn抖音美好体" in tag
|
||||
assert "\\b1" in tag
|
||||
assert "\\i0" in tag
|
||||
assert "\\3c&H00FFFFFF" in tag
|
||||
assert "\\bord2" in tag
|
||||
assert "\\4c&H00000000" in tag
|
||||
assert "\\xshad0" in tag
|
||||
assert "\\yshad4" in tag
|
||||
|
||||
def test_empty_override_dict_returns_empty(self):
|
||||
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0}]})
|
||||
assert style.build_line_override_tag(0, 1) == ""
|
||||
|
||||
def test_non_dict_items_filtered(self):
|
||||
# 从 dict 解析时已过滤,这里手动构造测试
|
||||
style = SubtitleStyle(line_overrides=["not a dict", {"line_index": 0, "color": "#FF0000"}])
|
||||
tag = style.build_line_override_tag(0, 1)
|
||||
assert "\\c&H000000FF" in tag
|
||||
|
||||
def test_total_lines_zero_returns_empty(self):
|
||||
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000"}]})
|
||||
assert style.build_line_override_tag(0, 0) == ""
|
||||
|
||||
def test_line_index_none_skipped(self):
|
||||
style = SubtitleStyle.from_dict({"line_overrides": [{"color": "#FF0000"}]}) # 无 line_index
|
||||
assert style.build_line_override_tag(0, 1) == ""
|
||||
|
||||
|
||||
class TestApplyLineOverrides:
|
||||
def test_empty_text_returns_empty(self):
|
||||
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000"}]})
|
||||
assert style.apply_line_overrides("") == ""
|
||||
|
||||
def test_no_overrides_returns_original(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.apply_line_overrides("hello\\Nworld") == "hello\\Nworld"
|
||||
|
||||
def test_single_line(self):
|
||||
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000", "font_size": 60}]})
|
||||
result = style.apply_line_overrides("单行标题")
|
||||
assert result.startswith("{")
|
||||
assert "单行标题" in result
|
||||
assert "\\c&H000000FF" in result
|
||||
|
||||
def test_multi_line_only_first_line_tagged(self):
|
||||
style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000", "font_size": 72}]})
|
||||
result = style.apply_line_overrides("第一行\\N第二行\\N第三行")
|
||||
lines = result.split("\\N")
|
||||
assert len(lines) == 3
|
||||
assert lines[0].startswith("{\\c&H000000FF")
|
||||
assert "第一行" in lines[0]
|
||||
assert lines[1] == "第二行" # 无标签
|
||||
assert lines[2] == "第三行"
|
||||
|
||||
def test_multi_line_middle_and_last(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"line_overrides": [
|
||||
{"line_index": 0, "font_size": 72, "bold": True},
|
||||
{"line_index": -1, "color": "#00FF00", "font_size": 36},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = style.apply_line_overrides("主标题\\N副标题\\N脚注")
|
||||
lines = result.split("\\N")
|
||||
assert lines[0].startswith("{\\fs72\\b1}")
|
||||
assert lines[1] == "副标题"
|
||||
assert "\\c&H0000FF00" in lines[2]
|
||||
assert "\\fs36" in lines[2]
|
||||
|
||||
def test_line_without_override_kept_verbatim(self):
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"line_overrides": [{"line_index": 1, "bold": True}],
|
||||
}
|
||||
)
|
||||
result = style.apply_line_overrides("第一行\\N第二行")
|
||||
lines = result.split("\\N")
|
||||
assert lines[0] == "第一行"
|
||||
assert lines[1].startswith("{\\b1}")
|
||||
assert "第二行" in lines[1]
|
||||
|
||||
|
||||
class TestFromDictLineOverrides:
|
||||
def test_from_dict_parses_line_overrides(self):
|
||||
cfg = {
|
||||
"line_overrides": [
|
||||
{"line_index": 0, "color": "#FF0000", "bold": True},
|
||||
{"line_index": 1, "font_size": 36},
|
||||
],
|
||||
}
|
||||
style = SubtitleStyle.from_dict(cfg)
|
||||
assert len(style.line_overrides) == 2
|
||||
assert style.line_overrides[0]["line_index"] == 0
|
||||
assert style.line_overrides[1]["font_size"] == 36
|
||||
|
||||
def test_from_dict_filters_non_dict_items(self):
|
||||
cfg = {"line_overrides": [{"line_index": 0, "color": "#FF0000"}, "bad", None, 123]}
|
||||
style = SubtitleStyle.from_dict(cfg)
|
||||
assert len(style.line_overrides) == 1
|
||||
|
||||
def test_from_dict_no_field_defaults_empty(self):
|
||||
style = SubtitleStyle.from_dict({"font": "微软雅黑"})
|
||||
assert style.line_overrides == []
|
||||
|
||||
def test_default_has_empty_list(self):
|
||||
style = SubtitleStyle()
|
||||
assert style.line_overrides == []
|
||||
|
||||
@@ -101,6 +101,9 @@ class TestTTSPreviewEndpoint:
|
||||
text="你好世界",
|
||||
voice_id="longxiaochun",
|
||||
speed=1.0,
|
||||
style="",
|
||||
volume=50,
|
||||
pitch=1.0,
|
||||
emotion="",
|
||||
language="zh-CN",
|
||||
)
|
||||
@@ -148,6 +151,9 @@ class TestTTSPreviewEndpoint:
|
||||
text="测试",
|
||||
voice_id="v1",
|
||||
speed=1.5,
|
||||
style="",
|
||||
volume=50,
|
||||
pitch=1.0,
|
||||
emotion="",
|
||||
language="zh-CN",
|
||||
)
|
||||
@@ -334,6 +340,9 @@ class TestTTSPreviewEndpoint:
|
||||
text="克隆音色测试",
|
||||
voice_id="cosyvoice_actual_voice_123",
|
||||
speed=1.0,
|
||||
style="",
|
||||
volume=50,
|
||||
pitch=1.0,
|
||||
emotion="",
|
||||
language="zh-CN",
|
||||
)
|
||||
@@ -412,6 +421,9 @@ class TestTTSPreviewEndpoint:
|
||||
text="预设音色测试",
|
||||
voice_id="longxiaoxia_v3",
|
||||
speed=1.0,
|
||||
style="",
|
||||
volume=50,
|
||||
pitch=1.0,
|
||||
emotion="",
|
||||
language="zh-CN",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user