Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 746899964b | |||
| f032152eaa | |||
| 9927413370 | |||
| 570a06f8c4 | |||
| 36e8f91e5e | |||
| ffb8ea6790 | |||
| cc01a5da69 | |||
| 7babea3d24 | |||
| a9f596fca3 | |||
| 9ba036abb3 | |||
| 3424e55a32 | |||
| 8c715474f4 | |||
| 93cb3e12a0 | |||
| e7bf85ca86 |
@@ -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:
|
||||
|
||||
@@ -93,7 +93,7 @@ def register_worker(
|
||||
svc: GpuLipsyncService = Depends(_get_svc),
|
||||
_token: str = Depends(_verify_gpu_token),
|
||||
):
|
||||
svc.register_worker(
|
||||
worker, cancel_task = svc.register_worker(
|
||||
worker_id=body.worker_id,
|
||||
hostname=body.hostname,
|
||||
gpu_name=body.gpu_name,
|
||||
@@ -101,7 +101,7 @@ def register_worker(
|
||||
capabilities=body.capabilities,
|
||||
task_id=body.task_id,
|
||||
)
|
||||
return GpuWorkerRegisterResponse(ok=True, server_time=datetime.now(UTC), message="ok")
|
||||
return GpuWorkerRegisterResponse(ok=True, server_time=datetime.now(UTC), message="ok", cancel_task=cancel_task)
|
||||
|
||||
|
||||
# ── GET /lipsync/poll — Worker 轮询拉任务 ─────────────────────────
|
||||
|
||||
@@ -111,9 +111,9 @@ def create_lipsync_job(
|
||||
voice_id=body.voice_id,
|
||||
script_text=body.script_text,
|
||||
speed=body.speed,
|
||||
volume=body.volume if hasattr(body, "volume") and body.volume is not None else 50,
|
||||
style=body.style or "",
|
||||
volume=body.volume if body.volume is not None else 50,
|
||||
emotion=body.emotion,
|
||||
style=body.style if hasattr(body, "style") else "",
|
||||
enable_video_loop=body.enable_video_loop,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
@@ -213,8 +213,9 @@ 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,
|
||||
style=getattr(body, "style", "") or "",
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
@@ -345,13 +346,13 @@ def cancel_lipsync_job(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""取消对口型任务(仅 pending/tts_processing/submitted 状态可取消)."""
|
||||
"""取消对口型任务(仅 pending/tts_processing/submitted/processing 状态可取消)."""
|
||||
job = svc.cancel_job(job_id, current_user.user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.status != "cancelled":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending/tts_processing/submitted 可取消",
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending/tts_processing/submitted/processing 可取消",
|
||||
)
|
||||
return job
|
||||
|
||||
@@ -203,12 +203,13 @@ def synthesize(
|
||||
# job.voice_id 统一存解析后的 CosyVoice voice_id
|
||||
actual_voice_id = resolved_profile.voice_id
|
||||
|
||||
# 语速/情绪/风格/音量等合成参数随 metadata 落库,workflow 提交 CosyVoice 时读取透传
|
||||
# 语速/情绪等合成参数随 metadata 落库,workflow 提交 CosyVoice 时读取透传
|
||||
synthesis_meta = {
|
||||
"speed": request.speed,
|
||||
"volume": request.volume if request.volume is not None else 50,
|
||||
"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_:
|
||||
@@ -656,10 +657,11 @@ 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,
|
||||
style=getattr(request, "style", "") or "",
|
||||
language=getattr(request, "language", "zh-CN"),
|
||||
pitch=getattr(request, "pitch", 1.0),
|
||||
)
|
||||
except (CosyVoiceError, ValueError) as e:
|
||||
# 合成失败退费
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@ class GpuWorkerRegisterResponse(BaseModel):
|
||||
ok: bool = True
|
||||
server_time: datetime
|
||||
message: str = "ok"
|
||||
cancel_task: bool = Field(False, description="当前心跳任务是否已被用户取消;为 true 时 Worker 应终止推理")
|
||||
|
||||
|
||||
# ── 轮询任务 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,14 +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,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)",
|
||||
)
|
||||
style: Optional[str] = Field(
|
||||
"",
|
||||
description="语气风格(natural/excited/professional/gentle/news/livestream),可选;与 emotion 互斥,style 优先级更高",
|
||||
description="[deprecated] 旧情绪参数,内部映射为 style",
|
||||
)
|
||||
|
||||
enable_video_loop: bool = Field(
|
||||
@@ -128,14 +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)",
|
||||
)
|
||||
style: Optional[str] = Field(
|
||||
"",
|
||||
description="语气风格(natural/excited/professional/gentle/news/livestream),可选;与 emotion 互斥,style 优先级更高",
|
||||
description="[deprecated] 旧情绪参数,内部映射为 style;默认 neutral",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,14 +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 自然语言指令控制)",
|
||||
)
|
||||
style: Optional[str] = Field(
|
||||
"",
|
||||
description="语气风格(natural/excited/professional/gentle/news/livestream),可选;与 emotion 互斥,style 优先级更高",
|
||||
description="[deprecated] 旧情绪参数,内部映射为 style;新接入请使用 style",
|
||||
)
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
|
||||
@@ -118,13 +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(
|
||||
"",
|
||||
description="语气风格(natural/excited/professional/gentle/news/livestream),可选;与 emotion 互斥,style 优先级更高",
|
||||
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="音调(0.5-2.0),1.0 为默认值")
|
||||
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)
|
||||
|
||||
@@ -50,13 +50,16 @@ class GpuLipsyncService:
|
||||
free_vram_mb: int = 0,
|
||||
capabilities: str = "musetalk",
|
||||
task_id: Optional[str] = None,
|
||||
) -> GpuWorkerModel:
|
||||
) -> tuple[GpuWorkerModel, bool]:
|
||||
"""Worker 注册/心跳。
|
||||
|
||||
task_id 非空时(Worker 推理期间的任务级心跳),同步把对应 processing
|
||||
任务的 last_heartbeat_at 续到当前时间,使长推理不会被
|
||||
``_recover_timed_out_tasks`` 误回退。任务已结束 / 不属于该 worker
|
||||
(如已被超时回收重新派发)时忽略,不报错。
|
||||
|
||||
返回 ``(worker, cancel_task)``:当心跳任务已被用户取消时
|
||||
``cancel_task=True``,Worker 应尽快终止推理并释放 GPU。
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
|
||||
@@ -77,10 +80,11 @@ class GpuLipsyncService:
|
||||
worker.free_vram_mb = free_vram_mb
|
||||
worker.capabilities = capabilities or worker.capabilities
|
||||
worker.last_heartbeat_at = now
|
||||
cancel_task = False
|
||||
if task_id:
|
||||
self._touch_task_heartbeat(task_id, worker_id, now)
|
||||
cancel_task = self._touch_task_heartbeat(task_id, worker_id, now)
|
||||
self.db.commit()
|
||||
return worker
|
||||
return worker, cancel_task
|
||||
|
||||
# ── 轮询拉任务(Worker 调用) ──────────────────────────────────
|
||||
|
||||
@@ -166,6 +170,11 @@ class GpuLipsyncService:
|
||||
task.result_duration = duration_seconds or 0.0
|
||||
task.error_msg = ""
|
||||
task.finished_at = now
|
||||
elif task.status == "cancelled":
|
||||
# 用户已取消的任务,Worker 终止后上报失败,保持 cancelled 状态不回退
|
||||
task.finished_at = now
|
||||
task.error_msg = (error_msg or "用户取消")[:2000]
|
||||
logger.info("GPU 任务 %s 已被用户取消,保持 cancelled 状态", task_id)
|
||||
else:
|
||||
# 失败:若仍可重试(已尝试次数 < MAX_ATTEMPTS)→ 回退 pending;否则 → failed
|
||||
if task.attempt < MAX_ATTEMPTS:
|
||||
@@ -250,15 +259,21 @@ class GpuLipsyncService:
|
||||
def _result_key(self, task_id: str) -> str:
|
||||
return f"{self.RESULT_PREFIX}{task_id}.mp4"
|
||||
|
||||
def _touch_task_heartbeat(self, task_id: str, worker_id: str, now: datetime) -> None:
|
||||
def _touch_task_heartbeat(self, task_id: str, worker_id: str, now: datetime) -> bool:
|
||||
"""Worker 推理期间的任务级心跳:只刷新属于该 worker 且仍在 processing 的任务。
|
||||
|
||||
任务不存在 / 已被超时回收重新派发 / 已完成 → 静默忽略(此时旧 worker 的
|
||||
结果上报会被结果接口按最终态处理)。
|
||||
|
||||
返回 ``cancel_task``:任务已被用户取消时为 True,Worker 应终止推理。
|
||||
"""
|
||||
task = self.db.get(GpuLipsyncTaskModel, task_id)
|
||||
if task is None:
|
||||
return
|
||||
return False
|
||||
# 任务已被用户取消 → 通知 Worker 终止推理
|
||||
if task.status == "cancelled":
|
||||
logger.info("任务心跳检测到已取消 task=%s worker=%s,通知 Worker 终止", task_id, worker_id)
|
||||
return True
|
||||
if task.status != "processing" or task.worker_id != worker_id:
|
||||
logger.info(
|
||||
"忽略过期任务心跳 task=%s worker=%s(status=%s owner=%s)",
|
||||
@@ -267,10 +282,11 @@ class GpuLipsyncService:
|
||||
task.status,
|
||||
task.worker_id,
|
||||
)
|
||||
return
|
||||
return False
|
||||
task.last_heartbeat_at = now
|
||||
task.updated_at = now
|
||||
self.db.flush()
|
||||
return False
|
||||
|
||||
def _touch_worker(self, worker_id: str, now: datetime) -> None:
|
||||
if not worker_id:
|
||||
@@ -371,9 +387,7 @@ class GpuLipsyncService:
|
||||
task = self.db.get(GpuLipsyncTaskModel, task_id)
|
||||
if task is None:
|
||||
return None
|
||||
if task.status == "done":
|
||||
return task
|
||||
if task.status == "failed":
|
||||
if task.status in ("done", "failed", "cancelled"):
|
||||
return task
|
||||
# pending/processing 继续等
|
||||
if time.monotonic() >= deadline:
|
||||
|
||||
@@ -111,6 +111,8 @@ class LipsyncService:
|
||||
script_text: str,
|
||||
speed: float,
|
||||
emotion: str,
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
) -> str:
|
||||
"""TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL.
|
||||
|
||||
@@ -124,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:
|
||||
@@ -424,9 +428,9 @@ class LipsyncService:
|
||||
voice_id: str = "",
|
||||
script_text: str = "",
|
||||
speed: float = 1.0,
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
emotion: str = "",
|
||||
style: str = "",
|
||||
enable_video_loop: bool = True,
|
||||
project_id: str = "",
|
||||
) -> LipsyncJobModel:
|
||||
@@ -474,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",
|
||||
@@ -495,9 +500,9 @@ class LipsyncService:
|
||||
voice_id,
|
||||
script_text,
|
||||
speed,
|
||||
emotion or "",
|
||||
volume,
|
||||
style or "",
|
||||
volume,
|
||||
emotion or "",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -531,8 +536,9 @@ class LipsyncService:
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float = 1.0,
|
||||
emotion: str = "neutral",
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
emotion: str = "neutral",
|
||||
) -> dict:
|
||||
"""同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算.
|
||||
|
||||
@@ -557,7 +563,6 @@ class LipsyncService:
|
||||
speed=speed,
|
||||
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
|
||||
language="zh",
|
||||
style=style,
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
|
||||
@@ -778,12 +783,32 @@ class LipsyncService:
|
||||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""取消任务(仅 pending/tts_processing/submitted 状态可取消)."""
|
||||
"""取消任务(pending/tts_processing/submitted/processing 状态可取消).
|
||||
|
||||
当 job 走 GPU 路径(mediakit_task_id 以 "gpu:" 开头)且状态为 processing 时,
|
||||
同步将关联的 GpuLipsyncTask 标记为 cancelled,以便 Worker 心跳时检测到取消信号。
|
||||
"""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
if job.status in ("pending", "tts_processing", "submitted"):
|
||||
if job.status in ("pending", "tts_processing", "submitted", "processing"):
|
||||
# GPU 路径:同步标记关联的 GPU 任务为 cancelled
|
||||
if job.status == "processing" and job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
|
||||
gpu_task_id = job.mediakit_task_id[4:] # 去掉 "gpu:" 前缀
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel
|
||||
|
||||
gpu_task = self.db.get(GpuLipsyncTaskModel, gpu_task_id)
|
||||
if gpu_task and gpu_task.status == "processing":
|
||||
gpu_task.status = "cancelled"
|
||||
gpu_task.error_msg = "用户取消"
|
||||
gpu_task.updated_at = datetime.now(UTC)
|
||||
gpu_task.finished_at = datetime.now(UTC)
|
||||
logger.info("GPU 任务 %s 已被用户取消(通过 job_id=%s)", gpu_task_id, job_id)
|
||||
except Exception as exc:
|
||||
logger.warning("标记 GPU 任务取消失败(不影响 job 取消): %s", exc)
|
||||
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
|
||||
@@ -98,6 +98,14 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
|
||||
_fallback_to_mediakit(db, job)
|
||||
return
|
||||
|
||||
if final_task.status == "cancelled":
|
||||
# 用户已取消任务,不回退 MediaKit,直接标记 job 为 cancelled
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info("[lipsync_gpu_async] GPU 任务已被用户取消: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
if final_task.status != "done":
|
||||
logger.warning(
|
||||
"[lipsync_gpu_async] GPU 失败,回退 MediaKit: job_id=%s gpu_task=%s status=%s",
|
||||
|
||||
@@ -82,9 +82,9 @@ def tts_synthesize_and_submit(
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float,
|
||||
emotion: str,
|
||||
volume: int = 50,
|
||||
style: str = "",
|
||||
volume: int = 50,
|
||||
emotion: str = "",
|
||||
):
|
||||
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
|
||||
|
||||
@@ -161,10 +161,10 @@ def tts_synthesize_and_submit(
|
||||
text=script_text,
|
||||
voice_id=voice_id,
|
||||
speed=speed,
|
||||
style=style,
|
||||
volume=volume,
|
||||
emotion=emotion,
|
||||
language="zh",
|
||||
volume=volume,
|
||||
style=style,
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
logger.error("[lipsync_tts] TTS 合成失败: job_id=%s err=%s", job_id, exc)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.style])
|
||||
}, [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,14 +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.style,
|
||||
])
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.style])
|
||||
|
||||
const handleRetryTts = useCallback(() => {
|
||||
handleGenerateTts()
|
||||
@@ -261,7 +250,6 @@ const AiAvatarPage: React.FC = () => {
|
||||
script_text: state.scriptText,
|
||||
video_url: videoUrl,
|
||||
speed: state.speed,
|
||||
emotion: normalizeEmotion(state.emotion),
|
||||
style: state.style,
|
||||
}
|
||||
}
|
||||
@@ -306,7 +294,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
state.selectedVoice,
|
||||
state.scriptText,
|
||||
state.speed,
|
||||
state.emotion,
|
||||
|
||||
state.style,
|
||||
state.ttsPreview,
|
||||
])
|
||||
@@ -607,8 +595,6 @@ 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}
|
||||
|
||||
@@ -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,20 +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"
|
||||
@@ -24,8 +21,6 @@ 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
|
||||
@@ -39,8 +34,6 @@ export function PanelVoiceSelector({
|
||||
onVoiceSourceChange,
|
||||
selectedVoice,
|
||||
onSelectVoice,
|
||||
emotion,
|
||||
onEmotionChange,
|
||||
style,
|
||||
onStyleChange,
|
||||
speed,
|
||||
@@ -152,14 +145,12 @@ export function PanelVoiceSelector({
|
||||
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) {
|
||||
@@ -171,7 +162,6 @@ export function PanelVoiceSelector({
|
||||
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,
|
||||
@@ -280,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">
|
||||
语言
|
||||
|
||||
@@ -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,
|
||||
@@ -35,7 +34,6 @@ 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")
|
||||
@@ -115,8 +113,6 @@ export function useAiAvatar() {
|
||||
setVoiceSource,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
emotion,
|
||||
setEmotion,
|
||||
style,
|
||||
setStyle,
|
||||
speed,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -640,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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
>,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -34,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -241,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,
|
||||
}
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
# MuseTalk 口型精度问题分析与修复方案
|
||||
|
||||
## 问题总结
|
||||
|
||||
运维逐帧分析确认:
|
||||
- ✅ 音频对齐正常(0ms偏移,相关系数0.99997)
|
||||
- ❌ **口型只跟音频能量张合,没有音素级精度**(发o/u没有圆唇,发m/b/p没有闭唇,静音段嘴巴还张着)
|
||||
- ❌ 5s循环边界有视觉跳变(帧间差异是正常的3-4倍)
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 根因 1:`musetalk_server.py` 的 `_run_inference()` 是 STUB 代码(最严重)
|
||||
|
||||
`deploy/gpu_worker/musetalk_server.py` 第 343-466 行:
|
||||
|
||||
```python
|
||||
# 2. 模拟 MuseTalk 推理:输入原视频帧 + 全量音频,输出音频时长的无声画面。
|
||||
# TODO: 替换为 MuseTalk 真实推理逻辑。
|
||||
logger.warning("使用示例推理逻辑,未实际调用 MuseTalk 模型")
|
||||
```
|
||||
|
||||
**当前代码从未调用 MuseTalk 模型**。整个推理流程是:
|
||||
1. 从视频提取帧(ffmpeg)
|
||||
2. 生成一段循环原视频的无声画面(ffmpeg `-stream_loop`)
|
||||
3. 用 ffmpeg `-c:v copy` 封装音频
|
||||
|
||||
**根本没有 MuseTalk 推理!** 所以"口型只跟能量张合"可能是因为 MuseTalk 的实际推理代码没被调用。
|
||||
|
||||
### 根因 2:音频格式未预处理
|
||||
|
||||
`gpu_worker.py` 把音频下载为 `input_audio.bin`(第 360 行),直接透传给 `musetalk_server.py`。
|
||||
`musetalk_server.py` 保存为 `input_audio.wav`(第 527 行)但**没有做任何格式转换**。
|
||||
|
||||
CosyVoice TTS 输出的音频格式:
|
||||
- **采样率**: 22050Hz(`cosyvoice_sample_rate = 22050`)
|
||||
- **格式**: MP3(`cosyvoice_format = "mp3"`)
|
||||
|
||||
MuseTalk 期望的输入:
|
||||
- **采样率**: 16000Hz
|
||||
- **声道**: mono
|
||||
- **格式**: WAV(16bit PCM)
|
||||
|
||||
**22050Hz MP3 → 16kHz mono 16bit WAV 的重采样转换完全没有做**。
|
||||
|
||||
这会导致 MuseTalk 的 whisper feature extractor 拿到错误采样率的音频,提取的 mel 频谱特征频率错位,音素识别完全错误。
|
||||
|
||||
### 根因 3:循环边界无过渡帧
|
||||
|
||||
`_mux_video_with_audio()` 的兜底路径(第 241-286 行)用 `ffmpeg -stream_loop -1` 直接循环视频,循环边界处:
|
||||
- 最后一帧 → 第一帧:没有过渡,硬切
|
||||
- 帧间差异是正常帧的 3-4 倍
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 修复 1:实现真正的 MuseTalk 推理调用
|
||||
|
||||
`_run_inference()` 必须调用真实的 MuseTalk 模型。参考 MuseTalk 官方推理流程:
|
||||
|
||||
```python
|
||||
def _run_inference_real(video_path, audio_path, output_path):
|
||||
"""真正的 MuseTalk 推理 — 替换 stub。"""
|
||||
# 1. 音频预处理:转换为 16kHz mono 16bit WAV
|
||||
preprocessed_audio = audio_path.parent / "audio_16k_mono.wav"
|
||||
_preprocess_audio(audio_path, preprocessed_audio)
|
||||
|
||||
# 2. 调用 MuseTalk 推理
|
||||
# MuseTalk 代码在 ~/projects/MuseTalk/ 下
|
||||
import sys
|
||||
muse_dir = Path.home() / "projects" / "MuseTalk"
|
||||
if str(muse_dir) not in sys.path:
|
||||
sys.path.insert(0, str(muse_dir))
|
||||
|
||||
from musetalk.utils.utils import get_file_type
|
||||
from musetalk.whisper.audio2feature import load_audio
|
||||
|
||||
# MuseTalk 内部推理 API(具体取决于部署的 MuseTalk 版本)
|
||||
# 典型调用:
|
||||
# model = MuseTalkModel(...)
|
||||
# result = model.infer(video=video_path, audio=preprocessed_audio)
|
||||
```
|
||||
|
||||
### 修复 2:音频预处理(必须)
|
||||
|
||||
在 `musetalk_server.py` 中添加 `_preprocess_audio()` 函数:
|
||||
|
||||
```python
|
||||
def _preprocess_audio(input_path: Path, output_path: Path, target_sr: int = 16000) -> None:
|
||||
"""将输入音频转换为 MuseTalk 要求的格式:16kHz mono 16bit WAV。
|
||||
|
||||
MuseTalk 的 whisper audio2feature 要求 16kHz 采样率,
|
||||
当前 TTS 输出 22050Hz MP3,不转换会导致 mel 频谱错位、
|
||||
音素特征提取错误,口型只跟能量不跟音素。
|
||||
"""
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(input_path),
|
||||
"-ar", str(target_sr), # 重采样到 16kHz
|
||||
"-ac", "1", # 单声道
|
||||
"-sample_fmt", "s16", # 16bit PCM
|
||||
"-f", "wav", # WAV 格式
|
||||
str(output_path),
|
||||
]
|
||||
_run_ffmpeg(cmd, timeout=60)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size < 100:
|
||||
raise RuntimeError(f"音频预处理失败: {output_path}")
|
||||
|
||||
# 验证输出格式
|
||||
try:
|
||||
import wave
|
||||
with wave.open(str(output_path), 'rb') as wf:
|
||||
sr = wf.getframerate()
|
||||
ch = wf.getnchannels()
|
||||
sw = wf.getsampwidth()
|
||||
if sr != target_sr or ch != 1 or sw != 2:
|
||||
logger.warning("音频格式异常: sr=%d ch=%d sw=%d", sr, ch, sw)
|
||||
except Exception as e:
|
||||
logger.warning("无法验证 WAV 格式: %s", e)
|
||||
|
||||
logger.info("音频预处理完成: %s → %s (%dHz mono 16bit WAV)",
|
||||
input_path.name, output_path.name, target_sr)
|
||||
```
|
||||
|
||||
### 修复 3:循环边界 crossfade
|
||||
|
||||
在兜底循环路径中,使用 ffmpeg `xfade` 滤镜在循环边界处混合:
|
||||
|
||||
```python
|
||||
def _mux_with_loop_crossfade(video_path, audio_path, output_path, crossfade_frames=3):
|
||||
"""循环视频 + crossfade 过渡,减少循环边界跳变。"""
|
||||
fps = _get_video_fps(video_path)
|
||||
crossfade_duration = crossfade_frames / fps # 通常 0.12s (3帧@25fps)
|
||||
video_duration = _get_media_duration(video_path)
|
||||
|
||||
# 使用 tpad 滤镜在视频末尾添加 crossfade
|
||||
# 方案:用 concat 把 [视频 + 视频前N帧crossfade] 拼起来
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-stream_loop", "-1",
|
||||
"-i", str(video_path),
|
||||
"-i", str(audio_path),
|
||||
"-filter_complex",
|
||||
f"[0:v]split=2[v1][v2];"
|
||||
f"[v2]trim=0:{crossfade_duration},setpts=PTS-STARTPTS+{video_duration - crossfade_duration}/TB[xfade_in];"
|
||||
f"[v1][xfade_in]xfade=transition=fade:duration={crossfade_duration}:offset={video_duration - crossfade_duration}[looped];"
|
||||
f"[looped]format=yuv420p[out]",
|
||||
"-map", "[out]",
|
||||
"-map", "1:a:0",
|
||||
"-c:v", encoder,
|
||||
"-preset", preset,
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-t", f"{audio_duration:.3f}",
|
||||
str(output_path),
|
||||
]
|
||||
```
|
||||
|
||||
**但更简单的方案**:如果 MuseTalk 正确处理了全量音频(输出时长=音频时长),就不需要兜底循环了。crossfade 只是兜底路径的优化。
|
||||
|
||||
### 修复 4:推理参数
|
||||
|
||||
MuseTalk 的关键推理参数:
|
||||
- `bbox_shift`: 口型区域偏移量,默认 0,范围 [-5, 5]。影响口型位置
|
||||
- `batch_size`: 推理批次大小,RTX2060 6G 显存建议 4-8
|
||||
- 视频帧率:MuseTalk 期望 25fps,当前已正确获取 fps
|
||||
|
||||
## 完整修复优先级
|
||||
|
||||
| 优先级 | 修复 | 影响 |
|
||||
|---|---|---|
|
||||
| **P0** | `_run_inference()` 调用真实 MuseTalk 模型 | **没有这个其他都没意义** |
|
||||
| **P0** | 音频预处理:22050Hz MP3 → 16kHz mono 16bit WAV | 音素特征正确提取 |
|
||||
| **P1** | 循环边界 crossfade | 减少视觉跳变 |
|
||||
| **P2** | bbox_shift 参数暴露为可配置 | 微调口型位置 |
|
||||
|
||||
## 在 RTX2060 上调试需要什么
|
||||
|
||||
1. **确认 MuseTalk 推理代码**:
|
||||
```bash
|
||||
# 在 RTX2060 上
|
||||
ls ~/projects/MuseTalk/
|
||||
cat ~/projects/MuseTalk/musetalk_server.py # 看实际部署版本
|
||||
```
|
||||
|
||||
2. **验证音频格式**:
|
||||
```bash
|
||||
# 在 musetalk_server.py 的推理前添加日志
|
||||
ffprobe -v error -show_entries stream=sample_rate,channels,codec_name,bits_per_sample \
|
||||
-of default /tmp/musetalk_*/input_audio.wav
|
||||
```
|
||||
|
||||
3. **提取中间结果对比**:
|
||||
```bash
|
||||
# 对比 TTS 原始音频 vs 预处理后的 16kHz WAV
|
||||
# 用 whisper 提取 mel 特征并可视化
|
||||
python -c "
|
||||
import whisper
|
||||
model = whisper.load_model('tiny')
|
||||
audio = whisper.load_audio('/tmp/test_audio.wav') # 预处理后的
|
||||
audio = whisper.pad_or_trim(audio)
|
||||
mel = whisper.log_mel_spectrogram(audio).to(model.device)
|
||||
# 保存 mel 特征供可视化
|
||||
import numpy as np
|
||||
np.save('/tmp/mel_features.npy', mel.cpu().numpy())
|
||||
"
|
||||
```
|
||||
|
||||
4. **MuseTalk 推理中间帧**:
|
||||
```bash
|
||||
# 在 MuseTalk 推理代码中保存中间帧到 /tmp/debug_frames/
|
||||
# 对比输入帧 vs 推理后帧 vs 原视频帧
|
||||
```
|
||||
|
||||
## 建议的下一步
|
||||
|
||||
1. 先在 RTX2060 上 `cat ~/projects/MuseTalk/musetalk_server.py` 确认实际部署的代码
|
||||
2. 确认 MuseTalk 是否真的被调用(看日志有没有 "使用示例推理逻辑" 的 warning)
|
||||
3. 如果确认是 stub,需要把真正的 MuseTalk 推理逻辑集成到 `_run_inference()`
|
||||
4. 同时修复音频预处理(P0),这个无论推理代码如何都是必须的
|
||||
@@ -108,15 +108,23 @@ def _check_musetalk_health() -> tuple[bool, dict]:
|
||||
return False, {"error": str(exc)}
|
||||
|
||||
|
||||
def _register(task_id: Optional[str] = None) -> bool:
|
||||
def _register(task_id: Optional[str] = None) -> tuple[bool, bool]:
|
||||
"""向服务端注册 / 心跳,附带 GPU 信息。
|
||||
|
||||
推理期间的心跳线程传 task_id:服务端会同步刷新该 processing 任务的
|
||||
last_heartbeat_at,防止长推理被误判超时回收。
|
||||
last_heartbeat_at,防止长推理被误判超时回收。同时服务端会检查该任务
|
||||
是否已被用户取消,若是则返回 cancel_task=True。
|
||||
|
||||
返回 (ok, cancel_task)。
|
||||
"""
|
||||
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()
|
||||
@@ -137,12 +145,14 @@ def _register(task_id: Optional[str] = None) -> bool:
|
||||
timeout=15,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return True
|
||||
resp_body = r.json()
|
||||
cancel_task = resp_body.get("cancel_task", False)
|
||||
return True, cancel_task
|
||||
logger.error("注册/心跳失败: HTTP %d body=%s", r.status_code, r.text[:300])
|
||||
return False
|
||||
return False, False
|
||||
except Exception as exc:
|
||||
logger.error("注册/心跳异常: %s", exc)
|
||||
return False
|
||||
return False, False
|
||||
|
||||
|
||||
def _probe_gpu_name() -> str:
|
||||
@@ -324,6 +334,9 @@ class TaskHeartbeat(threading.Thread):
|
||||
期间无法发送,服务端会因任务 last_heartbeat_at 停滞而误判超时回退 pending。
|
||||
本线程每 task_heartbeat_interval 秒(默认 30s)POST /gpu/register 并
|
||||
携带当前 task_id,让服务端持续续期任务心跳;任务处理结束 stop()。
|
||||
|
||||
同时检测服务端返回的 cancel_task 信号:若为 True,说明用户已取消任务,
|
||||
立即调用 _cancel_musetalk() 终止本地推理,并设置 cancelled 标志供主流程检查。
|
||||
"""
|
||||
|
||||
def __init__(self, task_id: str, interval: float):
|
||||
@@ -331,13 +344,21 @@ class TaskHeartbeat(threading.Thread):
|
||||
self.task_id = task_id
|
||||
self.interval = max(5.0, interval)
|
||||
self._stop_event = threading.Event()
|
||||
self.cancelled = False # 外部可读的取消标志
|
||||
|
||||
def run(self) -> None:
|
||||
# 先立即发一次,再按间隔循环(首次心跳失败不影响主流程)
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
if _register(self.task_id):
|
||||
ok, cancel_task = _register(self.task_id)
|
||||
if ok:
|
||||
logger.debug("任务 %s 心跳已发送", self.task_id)
|
||||
if cancel_task:
|
||||
logger.warning("任务 %s 已被用户取消,正在终止本地推理...", self.task_id)
|
||||
self.cancelled = True
|
||||
_cancel_musetalk()
|
||||
self._stop_event.set()
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("任务 %s 心跳异常(忽略): %s", self.task_id, exc)
|
||||
self._stop_event.wait(self.interval)
|
||||
@@ -364,9 +385,17 @@ def _handle_task(task: dict) -> None:
|
||||
if not _download(task["video_url"], video_path):
|
||||
_report_result(task_id, False, 0.0, "下载人物视频失败")
|
||||
return
|
||||
if hb.cancelled:
|
||||
logger.info("任务 %s 在下载阶段被用户取消", task_id)
|
||||
_report_result(task_id, False, 0.0, "用户取消任务")
|
||||
return
|
||||
if not _download(task["audio_url"], audio_path):
|
||||
_report_result(task_id, False, 0.0, "下载驱动音频失败")
|
||||
return
|
||||
if hb.cancelled:
|
||||
logger.info("任务 %s 在下载阶段被用户取消", task_id)
|
||||
_report_result(task_id, False, 0.0, "用户取消任务")
|
||||
return
|
||||
|
||||
# 2. 输入时长前置校验:短视频 MuseTalk 会 division by zero,
|
||||
# 直接上报 failed,不浪费 GPU 时间。ffprobe 不可用/读失败(0.0)
|
||||
@@ -387,12 +416,20 @@ def _handle_task(task: dict) -> None:
|
||||
err = ""
|
||||
retryable = False
|
||||
for attempt in range(Config.task_max_retry + 1):
|
||||
if hb.cancelled:
|
||||
logger.info("任务 %s 在推理前被用户取消", task_id)
|
||||
_report_result(task_id, False, 0.0, "用户取消任务")
|
||||
return
|
||||
if attempt > 0:
|
||||
logger.info("任务 %s 第 %d 次重试(瞬时错误)...", task_id, attempt + 1)
|
||||
time.sleep(2)
|
||||
success, duration, err, retryable = _call_musetalk(video_path, audio_path, out_path)
|
||||
if success or not retryable:
|
||||
break
|
||||
if hb.cancelled:
|
||||
logger.info("任务 %s 被用户取消(推理已终止)", task_id)
|
||||
_report_result(task_id, False, 0.0, "用户取消任务")
|
||||
return
|
||||
if not success:
|
||||
logger.error("任务 %s 推理失败: %s", task_id, err)
|
||||
_report_result(task_id, False, 0.0, err)
|
||||
@@ -462,7 +499,8 @@ def main() -> int:
|
||||
# 心跳
|
||||
now = time.time()
|
||||
if now - last_heartbeat >= Config.heartbeat_interval:
|
||||
if _register():
|
||||
ok, _ = _register()
|
||||
if ok:
|
||||
last_heartbeat = now
|
||||
|
||||
# 轮询任务
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
部署在 RTX2060 本地,接收 gpu_worker.py 的推理请求,调用 MuseTalk 生成口型同步视频。
|
||||
|
||||
Bug 修复(2026-09-21):
|
||||
- Bug1: 60fps降帧逻辑 — 输入视频 >30fps 时先降帧至 25fps 推理,直接输出 25fps 结果
|
||||
(MCI 运动补偿插帧已移除,CPU密集且口型场景 25fps 足够)
|
||||
- Bug2: 超时终止机制 — 推理线程改为 daemon + abort_event 机制,超时时 set event
|
||||
让推理循环检测退出,同时 kill 所有活跃 ffmpeg 子进程,等线程退出后再释放锁和清理目录
|
||||
- Bug3: /health 接口增加 gfpgan_loaded 和 gfpgan_load_error 字段
|
||||
- Bonus: 动态超时计算(基础120s + 帧数*0.15s,上限1800s)
|
||||
- Bonus: GFPGAN 增强异常时 log warning 而非静默跳过
|
||||
|
||||
#2000 关键修复:
|
||||
- 集成真实 MuseTalk 推理(替换原有 stub 代码)
|
||||
- 音频预处理:22050Hz MP3 → 16kHz mono 16bit WAV(MuseTalk 要求)
|
||||
@@ -87,12 +96,23 @@ class Config:
|
||||
use_float16: bool = _env("MUSE_USE_FLOAT16", "1") == "1"
|
||||
# 推理批次大小(RTX2060 6G 显存建议 4-8)
|
||||
batch_size: int = int(_env("MUSE_BATCH_SIZE", "8"))
|
||||
# GFPGAN 人脸超分增强(提升生成人脸清晰度,+~170MB VRAM, +60ms/帧)
|
||||
use_gfpgan: bool = _env("MUSE_USE_GFPGAN", "1") == "1"
|
||||
|
||||
|
||||
# ── 全局状态 ──────────────────────────────────────────────────────────
|
||||
inference_lock = threading.Lock()
|
||||
current_task: dict = {"task_id": None, "process": None, "start_time": 0.0}
|
||||
shutdown_event = threading.Event()
|
||||
# 当前推理线程的终止信号和线程引用
|
||||
current_abort_event: Optional[threading.Event] = None
|
||||
current_thread: Optional[threading.Thread] = None
|
||||
# 全局追踪正在运行的 ffmpeg 子进程(用于超时终止)
|
||||
_active_ffmpeg_procs: list[subprocess.Popen] = []
|
||||
_active_ffmpeg_lock = threading.Lock()
|
||||
# GFPGAN 加载状态(供 /health 接口查询)
|
||||
_gfpgan_loaded = False
|
||||
_gfpgan_load_error: Optional[str] = None
|
||||
|
||||
# ── MuseTalk 模型懒加载 ─────────────────────────────────────────────
|
||||
_muse_models = None
|
||||
@@ -372,21 +392,33 @@ def _check_file_size(file, max_mb: int, label: str) -> Optional[str]:
|
||||
|
||||
|
||||
def _run_ffmpeg(cmd: list, timeout: float = 120) -> subprocess.CompletedProcess:
|
||||
"""运行 ffmpeg 命令,检查返回码和超时."""
|
||||
"""运行 ffmpeg 命令,检查返回码和超时。进程会被注册到全局列表以便外部终止."""
|
||||
proc = None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=True,
|
||||
)
|
||||
return result
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr.decode(errors="ignore") if exc.stderr else ""
|
||||
raise RuntimeError(f"ffmpeg 失败 (code={exc.returncode}): {stderr[:500]}") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(f"ffmpeg 超时(>{timeout}s)") from exc
|
||||
with _active_ffmpeg_lock:
|
||||
_active_ffmpeg_procs.append(proc)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
raise RuntimeError(f"ffmpeg 超时(>{timeout}s)")
|
||||
if proc.returncode != 0:
|
||||
stderr_text = stderr.decode(errors="ignore") if stderr else ""
|
||||
raise RuntimeError(f"ffmpeg 失败 (code={proc.returncode}): {stderr_text[:500]}")
|
||||
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
||||
finally:
|
||||
if proc is not None:
|
||||
with _active_ffmpeg_lock:
|
||||
try:
|
||||
_active_ffmpeg_procs.remove(proc)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# ── MuseTalk 模型加载 ────────────────────────────────────────────────
|
||||
@@ -399,7 +431,7 @@ def _load_musetalk_models():
|
||||
加载到 GPU 后转为 FP16(如果配置开启)以节省显存。
|
||||
RTX2060 6G 显存,FP16 大约需要 3-4GB。
|
||||
"""
|
||||
global _muse_models, _muse_models_loaded, _muse_load_error
|
||||
global _muse_models, _muse_models_loaded, _muse_load_error, _gfpgan_loaded, _gfpgan_load_error
|
||||
|
||||
if _muse_models_loaded:
|
||||
return _muse_models
|
||||
@@ -477,6 +509,43 @@ def _load_musetalk_models():
|
||||
audio_processor = AudioProcessor()
|
||||
face_parsing = FaceParsing()
|
||||
|
||||
# 加载 GFPGAN 人脸超分模型(FP16,仅 ~170MB VRAM)
|
||||
gfpgan_model = None
|
||||
if Config.use_gfpgan:
|
||||
try:
|
||||
from gfpgan.archs.gfpganv1_clean_arch import GFPGANv1Clean
|
||||
gfpgan_path = muse_dir / "models" / "GFPGAN" / "GFPGANv1.4.pth"
|
||||
if gfpgan_path.exists():
|
||||
logger.info("加载 GFPGANv1.4 人脸超分模型: %s", gfpgan_path)
|
||||
gfpgan_ckpt = torch.load(str(gfpgan_path), map_location="cpu")
|
||||
gfpgan_model = GFPGANv1Clean(
|
||||
out_size=512, num_style_feat=512, channel_multiplier=2,
|
||||
decoder_load_path=None, fix_decoder=False, num_mlp=8,
|
||||
input_is_latent=True, different_w=True, narrow=1, sft_half=True,
|
||||
)
|
||||
gfpgan_key = "params_ema" if "params_ema" in gfpgan_ckpt else "params"
|
||||
gfpgan_model.load_state_dict(gfpgan_ckpt[gfpgan_key], strict=True)
|
||||
gfpgan_model.eval()
|
||||
# GFPGAN 始终使用 FP32 推理,避免 FP16 色偏导致紫/灰色块
|
||||
gfpgan_model = gfpgan_model.to(device)
|
||||
del gfpgan_ckpt
|
||||
_gfpgan_loaded = True
|
||||
_gfpgan_load_error = None
|
||||
logger.info("GFPGAN 加载完成 (FP32,避免色偏)")
|
||||
else:
|
||||
_gfpgan_loaded = False
|
||||
_gfpgan_load_error = f"模型文件不存在: {gfpgan_path}"
|
||||
logger.warning("GFPGAN 模型不存在: %s,跳过人脸增强", gfpgan_path)
|
||||
except Exception as e:
|
||||
_gfpgan_loaded = False
|
||||
_gfpgan_load_error = str(e)
|
||||
logger.warning("GFPGAN 加载失败,跳过人脸增强: %s", e)
|
||||
gfpgan_model = None
|
||||
else:
|
||||
_gfpgan_loaded = False
|
||||
_gfpgan_load_error = "已通过环境变量禁用 (MUSE_USE_GFPGAN=0)"
|
||||
logger.info("GFPGAN 已禁用 (MUSE_USE_GFPGAN=0)")
|
||||
|
||||
_muse_models = {
|
||||
"vae": vae,
|
||||
"unet": unet,
|
||||
@@ -484,6 +553,7 @@ def _load_musetalk_models():
|
||||
"timesteps": timesteps,
|
||||
"audio_processor": audio_processor,
|
||||
"face_parsing": face_parsing,
|
||||
"gfpgan": gfpgan_model,
|
||||
"device": device,
|
||||
"model_version": model_version,
|
||||
}
|
||||
@@ -529,6 +599,7 @@ def _run_inference(
|
||||
audio_path: Path,
|
||||
output_path: Path,
|
||||
bbox_shift: int = 0,
|
||||
abort_event: threading.Event = None,
|
||||
) -> None:
|
||||
"""执行 MuseTalk 真实推理.
|
||||
|
||||
@@ -552,9 +623,38 @@ def _run_inference(
|
||||
import torch
|
||||
from tqdm import tqdm
|
||||
|
||||
from musetalk.utils.preprocessing import get_landmark_and_bbox, read_imgs
|
||||
from musetalk.utils.utils import datagen
|
||||
from musetalk.utils.preprocessing import get_landmark_and_bbox as _orig_get_landmark_and_bbox
|
||||
from musetalk.utils.blending import get_image
|
||||
import tempfile as _tempfile, math as _math, shutil as _shutil
|
||||
from einops import rearrange as _rearrange
|
||||
|
||||
# read_imgs: 读取视频帧(支持视频文件路径),返回 numpy BGR 帧列表
|
||||
def read_imgs(path):
|
||||
import cv2 as _cv2
|
||||
cap = _cv2.VideoCapture(str(path))
|
||||
frames = []
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
frames.append(frame)
|
||||
cap.release()
|
||||
return frames
|
||||
|
||||
# get_landmark_and_bbox 适配:旧版签名(img_list, upperbondrange=0),且 img_list 是文件路径列表
|
||||
def get_landmark_and_bbox(frames, vid_pts=0, bbox_shift=0):
|
||||
import cv2 as _cv2
|
||||
_tmpdir = _tempfile.mkdtemp(prefix="muse_frames_")
|
||||
frame_paths = []
|
||||
for _i, _frm in enumerate(frames):
|
||||
_fp = f"{_tmpdir}/{_i:08d}.png"
|
||||
_cv2.imwrite(_fp, _frm)
|
||||
frame_paths.append(_fp)
|
||||
coords_list, _ = _orig_get_landmark_and_bbox(frame_paths, upperbondrange=bbox_shift)
|
||||
_shutil.rmtree(_tmpdir, ignore_errors=True)
|
||||
_sentinel = object()
|
||||
coords_list = [c if c is not None else _sentinel for c in coords_list]
|
||||
return coords_list, _sentinel
|
||||
|
||||
# 加载模型(首次调用时加载,后续复用)
|
||||
models = _load_musetalk_models()
|
||||
@@ -566,14 +666,89 @@ def _run_inference(
|
||||
device = models["device"]
|
||||
model_version = models["model_version"]
|
||||
|
||||
# 给旧版 AudioProcessor 动态添加 feature2chunks 方法
|
||||
import types as _types
|
||||
def _feature2chunks(self, feature_array, fps=25, weight_dtype=None,
|
||||
batch_size=8, audio_padding_length_left=2,
|
||||
audio_padding_length_right=2):
|
||||
import torch
|
||||
sr = 16000
|
||||
audio_fps = 50
|
||||
chunk_len = 2 * (audio_padding_length_left + audio_padding_length_right + 1)
|
||||
whisper_idx_multiplier = audio_fps / fps
|
||||
num_frames = int(_math.floor((len(feature_array) / sr) * fps))
|
||||
actual_length = int(_math.floor((len(feature_array) / sr) * audio_fps))
|
||||
inputs = self.feature_extractor(
|
||||
feature_array, return_tensors="pt", sampling_rate=sr
|
||||
).input_features.to(device)
|
||||
if weight_dtype is not None:
|
||||
inputs = inputs.to(dtype=weight_dtype)
|
||||
global _whisper_enc_model
|
||||
if "_whisper_enc_model" not in globals() or _whisper_enc_model is None:
|
||||
from transformers import WhisperModel
|
||||
_wp = str(Path(Config.muse_dir) / "models" / "whisper")
|
||||
_whisper_enc_model = WhisperModel.from_pretrained(_wp).to(device)
|
||||
_whisper_enc_model.eval()
|
||||
if Config.use_float16:
|
||||
_whisper_enc_model = _whisper_enc_model.half()
|
||||
with torch.no_grad():
|
||||
_af = _whisper_enc_model.encoder(inputs, output_hidden_states=True).hidden_states
|
||||
_af = torch.stack(_af, dim=2)
|
||||
_af = _af[0, :actual_length, ...]
|
||||
_pn = int(_math.ceil(whisper_idx_multiplier))
|
||||
_af = torch.cat([
|
||||
torch.zeros_like(_af[:_pn * audio_padding_length_left]),
|
||||
_af,
|
||||
torch.zeros_like(_af[:_pn * 3 * audio_padding_length_right]),
|
||||
], dim=0)
|
||||
_all = []
|
||||
for _fi in range(num_frames):
|
||||
_ai = int(_math.floor(_fi * whisper_idx_multiplier))
|
||||
_clip = _af[_ai:_ai + chunk_len]
|
||||
if _clip.shape[0] < chunk_len:
|
||||
_pad = torch.zeros(chunk_len - _clip.shape[0], *_clip.shape[1:],
|
||||
device=device, dtype=_clip.dtype)
|
||||
_clip = torch.cat([_clip, _pad], dim=0)
|
||||
_all.append(_clip)
|
||||
_prompts = torch.stack(_all, dim=0)
|
||||
_prompts = _rearrange(_prompts, "b c h w -> b (c h) w")
|
||||
return _prompts
|
||||
audio_processor.feature2chunks = _types.MethodType(_feature2chunks, audio_processor)
|
||||
|
||||
fps = _get_video_fps(video_path)
|
||||
original_fps = fps
|
||||
audio_duration = _get_media_duration(audio_path)
|
||||
video_duration = _get_media_duration(video_path)
|
||||
logger.info(
|
||||
"MuseTalk 推理开始: video=%.2fs, audio=%.2fs, fps=%.1f, bbox_shift=%d",
|
||||
"MuseTalk 推理开始: video=%.2fs, audio=%.2fs, input_fps=%.1f, bbox_shift=%d",
|
||||
video_duration, audio_duration, fps, bbox_shift,
|
||||
)
|
||||
|
||||
# ── Step 0: 高帧率视频降帧(>30fps → 25fps 推理,推理后再插帧回原帧率)──
|
||||
inference_fps = fps
|
||||
video_downsampled = False
|
||||
if fps > 30:
|
||||
inference_fps = 25.0
|
||||
downsampled_video = video_path.parent / "input_25fps.mp4"
|
||||
logger.info("检测到高帧率视频 %.1f fps,降帧至 %.1f fps 进行推理", fps, inference_fps)
|
||||
try:
|
||||
_run_ffmpeg([
|
||||
"ffmpeg", "-y", "-v", "warning",
|
||||
"-i", str(video_path),
|
||||
"-r", str(inference_fps),
|
||||
"-c:v", "libx264", "-preset", "veryfast",
|
||||
"-crf", "18", "-pix_fmt", "yuv420p",
|
||||
str(downsampled_video),
|
||||
], timeout=120)
|
||||
video_path = downsampled_video
|
||||
video_downsampled = True
|
||||
# 更新帧数和时长
|
||||
total_input_frames_approx = int(audio_duration * inference_fps)
|
||||
logger.info("降帧完成: input_fps=%.1f → inference_fps=%.1f", original_fps, inference_fps)
|
||||
except Exception as e:
|
||||
logger.warning("视频降帧失败,使用原始帧率 %.1f fps 推理: %s", fps, e)
|
||||
inference_fps = fps
|
||||
|
||||
# ── Step 1: 音频预处理(关键修复:22050Hz MP3 → 16kHz mono WAV)──
|
||||
audio_wav_path = video_path.parent / "audio_16k_mono.wav"
|
||||
_preprocess_audio(audio_path, audio_wav_path, target_sr=16000)
|
||||
@@ -592,7 +767,7 @@ def _run_inference(
|
||||
logger.info("人脸检测完成,有效 bbox: %d/%d", sum(1 for c in coord_list if c is not coord_placeholder), total_frames)
|
||||
|
||||
# 使用 mirror indexing 循环帧和坐标(避免硬切跳变)
|
||||
num_output_frames = int(audio_duration * fps)
|
||||
num_output_frames = int(audio_duration * inference_fps)
|
||||
if num_output_frames <= 0:
|
||||
num_output_frames = total_frames
|
||||
|
||||
@@ -602,138 +777,238 @@ def _run_inference(
|
||||
audio_array, _ = librosa.load(str(audio_wav_path), sr=16000, mono=True)
|
||||
whisper_features = audio_processor.feature2chunks(
|
||||
feature_array=audio_array,
|
||||
fps=fps,
|
||||
fps=inference_fps,
|
||||
weight_dtype=(torch.float16 if Config.use_float16 else torch.float32),
|
||||
batch_size=Config.batch_size,
|
||||
)
|
||||
if isinstance(whisper_features, torch.Tensor):
|
||||
whisper_features = whisper_features.detach().cpu()
|
||||
torch.cuda.empty_cache()
|
||||
logger.info("音频特征提取完成: %d 个 chunk", len(whisper_features))
|
||||
|
||||
# ── Step 5: 视频帧 VAE 编码为 latent ──
|
||||
# ── Step 5: 逐帧裁剪人脸并编码为 8ch latent (masked+ref) ──
|
||||
face_parsing = models.get("face_parsing", None)
|
||||
input_latent_list = []
|
||||
valid_frame_indices = [] # 记录成功编码的帧索引(跳过无脸帧)
|
||||
|
||||
with torch.no_grad():
|
||||
for frame in input_frames:
|
||||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
frame_resized = cv2.resize(frame_rgb, (256, 256))
|
||||
frame_tensor = torch.from_numpy(frame_resized).float() / 127.5 - 1.0
|
||||
frame_tensor = frame_tensor.permute(2, 0, 1).unsqueeze(0).to(device)
|
||||
if Config.use_float16:
|
||||
frame_tensor = frame_tensor.half()
|
||||
latent = vae.encode_latents(frame_tensor)
|
||||
input_latent_list.append(latent)
|
||||
logger.info("视频帧 VAE 编码完成: %d 个 latent", len(input_latent_list))
|
||||
for idx, (frame, bbox) in enumerate(zip(input_frames, coord_list)):
|
||||
if bbox is coord_placeholder:
|
||||
continue
|
||||
x1, y1, x2, y2 = bbox
|
||||
# v1.5 额外扩展下边界(下巴区域),与 Step 7 保持一致
|
||||
extra_y2 = 10 if model_version == "v15" else 0
|
||||
y2_eff = min(y2 + extra_y2, frame.shape[0])
|
||||
if y2_eff <= y1 or x2 <= x1:
|
||||
continue
|
||||
# 裁剪人脸区域 → resize 256×256
|
||||
crop = frame[y1:y2_eff, x1:x2]
|
||||
if crop.size == 0:
|
||||
continue
|
||||
crop_rgb = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)
|
||||
crop_resized = cv2.resize(crop_rgb, (256, 256), interpolation=cv2.INTER_LANCZOS4)
|
||||
# 使用 VAE 的 get_latents_for_unet 得到 8 通道输入
|
||||
# get_latents_for_unet 内部: preprocess(half_mask=True) encode + preprocess(half_mask=False) encode → cat → [1,8,32,32]
|
||||
latents = vae.get_latents_for_unet(crop_resized).detach().cpu()
|
||||
input_latent_list.append(latents)
|
||||
# 保存此帧的实际bbox(含extra_y2)和原帧索引供 Step 7 使用
|
||||
valid_frame_indices.append((idx, x1, y1, x2, y2_eff))
|
||||
|
||||
# ── Step 6: 批量推理 ──
|
||||
result_frames = []
|
||||
total_batches = (num_output_frames + Config.batch_size - 1) // Config.batch_size
|
||||
# 构建循环列表:正序+倒序,实现旧版的平滑首尾帧循环
|
||||
frame_list_cycle = input_frames + input_frames[::-1]
|
||||
coord_cycle = []
|
||||
for _i, _x1, _y1, _x2, _y2 in valid_frame_indices:
|
||||
coord_cycle.append((_x1, _y1, _x2, _y2))
|
||||
coord_cycle = coord_cycle + coord_cycle[::-1]
|
||||
latent_cycle = input_latent_list + input_latent_list[::-1]
|
||||
valid_cycle = valid_frame_indices + [(i, x1, y1, x2, y2) for (i, x1, y1, x2, y2) in reversed(valid_frame_indices)]
|
||||
|
||||
for batch_idx in tqdm(range(total_batches), desc="MuseTalk 推理"):
|
||||
# 构建 whisper batch
|
||||
whisper_batch = whisper_features[
|
||||
batch_idx * Config.batch_size : (batch_idx + 1) * Config.batch_size
|
||||
]
|
||||
torch.cuda.empty_cache()
|
||||
logger.info("人脸裁剪+VAE 编码完成: %d 个有效latent", len(input_latent_list))
|
||||
|
||||
# ── Step 6: 批量推理(仿旧版 datagen 循环)──
|
||||
res_frame_list = []
|
||||
video_num = len(whisper_features)
|
||||
bs = min(Config.batch_size, 8) # RTX3060 12G 显存,FP16+GFPGAN batch=8 约用 7-8GB,留足余量
|
||||
total_batches = (video_num + bs - 1) // bs
|
||||
|
||||
for bi in tqdm(range(total_batches), desc="MuseTalk 推理"):
|
||||
# 检查是否收到终止信号
|
||||
if abort_event is not None and abort_event.is_set():
|
||||
logger.warning("收到终止信号,中止推理 (batch %d/%d)", bi, total_batches)
|
||||
raise InterruptedError("推理被外部终止")
|
||||
whisper_batch = whisper_features[bi*bs:(bi+1)*bs]
|
||||
if len(whisper_batch) == 0:
|
||||
break
|
||||
# 对应 latent 索引(循环取 latent_cycle)
|
||||
latent_batch_parts = []
|
||||
for j in range(len(whisper_batch)):
|
||||
global_idx = bi*bs + j
|
||||
lat_idx = global_idx % len(latent_cycle)
|
||||
latent_batch_parts.append(latent_cycle[lat_idx])
|
||||
|
||||
# 构建 latent batch(使用 mirror indexing 循环)
|
||||
latent_indices = []
|
||||
for i in range(len(whisper_batch)):
|
||||
global_idx = batch_idx * Config.batch_size + i
|
||||
frame_idx = _mirror_index(total_frames, global_idx)
|
||||
latent_indices.append(frame_idx)
|
||||
|
||||
latent_batch = torch.cat(
|
||||
[input_latent_list[idx] for idx in latent_indices], dim=0
|
||||
)
|
||||
latent_batch = latent_batch.to(device)
|
||||
# whisper_batch 是 [bs,50,384] tensor slice (feature2chunks 已返回 stacked tensor)
|
||||
if isinstance(whisper_batch, list):
|
||||
whisper_batch_t = torch.stack(whisper_batch).to(device)
|
||||
else:
|
||||
whisper_batch_t = whisper_batch.to(device)
|
||||
latent_batch_t = torch.cat(latent_batch_parts, dim=0).to(device)
|
||||
if Config.use_float16:
|
||||
latent_batch = latent_batch.half()
|
||||
latent_batch_t = latent_batch_t.to(dtype=unet.model.dtype)
|
||||
whisper_batch_t = whisper_batch_t.to(dtype=unet.model.dtype)
|
||||
|
||||
# 位置编码
|
||||
audio_feature_batch = pe(whisper_batch)
|
||||
audio_feature_batch = pe(whisper_batch_t)
|
||||
|
||||
# UNet 推理
|
||||
with torch.no_grad():
|
||||
latent_batch = latent_batch.to(dtype=unet.model.dtype)
|
||||
pred_latents = unet.model(
|
||||
latent_batch,
|
||||
latent_batch_t,
|
||||
timesteps,
|
||||
encoder_hidden_states=audio_feature_batch,
|
||||
).sample
|
||||
|
||||
# VAE 解码
|
||||
recon_frames = vae.decode_latents(pred_latents)
|
||||
result_frames.extend(recon_frames)
|
||||
for rf in recon_frames:
|
||||
res_frame_list.append(rf)
|
||||
del pred_latents, recon_frames, latent_batch_t, whisper_batch_t
|
||||
if "audio_feature_batch" in dir():
|
||||
try: del audio_feature_batch
|
||||
except: pass
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
logger.info("推理完成,生成 %d 帧", len(result_frames))
|
||||
logger.info("推理完成,生成 %d 帧", len(res_frame_list))
|
||||
|
||||
# ── Step 7: 合成最终帧并写视频 ──
|
||||
output_frames_dir = video_path.parent / "output_frames"
|
||||
output_frames_dir.mkdir(parents=True, exist_ok=True)
|
||||
# ── Step 7: 合成最终帧 → ffmpeg pipe 编码(零磁盘IO) ──
|
||||
gfpgan_enhancer = models.get("gfpgan")
|
||||
silent_video_path = video_path.parent / "silent_output.mp4"
|
||||
frame_h, frame_w = frame_list_cycle[0].shape[:2]
|
||||
|
||||
for i, res_frame in enumerate(tqdm(result_frames[:num_output_frames], desc="合成帧")):
|
||||
orig_idx = _mirror_index(total_frames, i)
|
||||
ori_frame = copy.deepcopy(input_frames[orig_idx])
|
||||
bbox = coord_list[orig_idx] if orig_idx < len(coord_list) else coord_placeholder
|
||||
# 启动 ffmpeg:stdin 接收 raw BGR24 帧,直接编码 H.264(省去PNG落盘+回读)
|
||||
_ff_cmd = [
|
||||
"ffmpeg", "-y", "-v", "warning",
|
||||
"-f", "rawvideo", "-pix_fmt", "bgr24",
|
||||
"-s", f"{frame_w}x{frame_h}", "-r", str(inference_fps),
|
||||
"-i", "-",
|
||||
"-vcodec", "libx264", "-preset", "veryfast",
|
||||
"-vf", "format=yuv420p", "-crf", "18",
|
||||
str(silent_video_path),
|
||||
]
|
||||
import subprocess as _sp
|
||||
_ff_proc = _sp.Popen(_ff_cmd, stdin=_sp.PIPE, stdout=_sp.DEVNULL, stderr=_sp.PIPE)
|
||||
# 注册 ffmpeg 进程到全局列表,以便超时终止
|
||||
with _active_ffmpeg_lock:
|
||||
_active_ffmpeg_procs.append(_ff_proc)
|
||||
|
||||
n_out = min(len(res_frame_list), num_output_frames)
|
||||
try:
|
||||
for i in tqdm(range(n_out), desc="合成帧"):
|
||||
# 检查终止信号
|
||||
if abort_event is not None and abort_event.is_set():
|
||||
logger.warning("收到终止信号,中止合成 (frame %d/%d)", i, n_out)
|
||||
raise InterruptedError("推理被外部终止")
|
||||
cyc_i = i % len(coord_cycle)
|
||||
x1, y1, x2, y2 = coord_cycle[cyc_i]
|
||||
ori_frame = copy.deepcopy(frame_list_cycle[cyc_i])
|
||||
res_frame = res_frame_list[i]
|
||||
|
||||
if bbox is coord_placeholder:
|
||||
# 没有检测到人脸的帧,保持原样
|
||||
combined = ori_frame
|
||||
else:
|
||||
x1, y1, x2, y2 = bbox
|
||||
if model_version == "v15":
|
||||
# v1.5 额外扩展下边界(下巴区域)
|
||||
y2 = min(y2 + 10, ori_frame.shape[0])
|
||||
try:
|
||||
res_frame_resized = cv2.resize(
|
||||
res_frame.astype(np.uint8), (x2 - x1, y2 - y1)
|
||||
res_frame.astype(np.uint8), (x2-x1, y2-y1),
|
||||
interpolation=cv2.INTER_LANCZOS4
|
||||
)
|
||||
except Exception:
|
||||
combined = ori_frame
|
||||
cv2.imwrite(
|
||||
str(output_frames_dir / f"{i:08d}.png"), combined
|
||||
)
|
||||
_ff_proc.stdin.write(ori_frame.tobytes())
|
||||
continue
|
||||
|
||||
# 使用 face parsing 做边缘融合
|
||||
combined = get_image(
|
||||
ori_frame,
|
||||
res_frame_resized,
|
||||
[x1, y1, x2, y2],
|
||||
)
|
||||
# GFPGAN 人脸超分增强(FP32 + 色彩校正,避免 FP16 色偏)
|
||||
if gfpgan_enhancer is not None:
|
||||
try:
|
||||
_fh, _fw = res_frame_resized.shape[:2]
|
||||
_face_up = cv2.resize(res_frame_resized, (512, 512),
|
||||
interpolation=cv2.INTER_LANCZOS4)
|
||||
# 保存原始人脸区域用于色彩校正
|
||||
_face_original_bgr = _face_up.copy()
|
||||
_face_rgb = cv2.cvtColor(_face_up, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
||||
_face_t = torch.from_numpy(_face_rgb.transpose(2,0,1)).unsqueeze(0)
|
||||
# GFPGAN 始终 FP32,避免 FP16 精度导致色偏;归一化到 [-1, 1]
|
||||
_face_t = ((_face_t - 0.5) / 0.5).to(device)
|
||||
with torch.no_grad():
|
||||
_out = gfpgan_enhancer(_face_t, return_rgb=False, weight=0.35)[0]
|
||||
_out = _out.squeeze(0).float().cpu().clamp_(-1,1)
|
||||
_out = ((_out + 1)/2*255).numpy().transpose(1,2,0)
|
||||
_out_bgr = cv2.cvtColor(_out.astype(np.uint8), cv2.COLOR_RGB2BGR)
|
||||
|
||||
cv2.imwrite(str(output_frames_dir / f"{i:08d}.png"), combined)
|
||||
# 色彩校正:将增强结果的均值/标准差对齐到原始人脸,消除色调偏移
|
||||
_orig_mean = _face_original_bgr.mean(axis=(0, 1))
|
||||
_orig_std = _face_original_bgr.std(axis=(0, 1)) + 1e-6
|
||||
_enh_mean = _out_bgr.mean(axis=(0, 1))
|
||||
_enh_std = _out_bgr.std(axis=(0, 1)) + 1e-6
|
||||
_out_bgr = ((_out_bgr.astype(np.float32) - _enh_mean) * (_orig_std / _enh_std) + _orig_mean)
|
||||
_out_bgr = np.clip(_out_bgr, 0, 255).astype(np.uint8)
|
||||
|
||||
# 帧序列 → 无声视频
|
||||
silent_video_path = video_path.parent / "silent_output.mp4"
|
||||
_run_ffmpeg(
|
||||
[
|
||||
"ffmpeg", "-y", "-v", "warning",
|
||||
"-r", str(fps),
|
||||
"-f", "image2",
|
||||
"-i", str(output_frames_dir / "%08d.png"),
|
||||
"-vcodec", "libx264",
|
||||
"-vf", "format=yuv420p",
|
||||
"-crf", "18",
|
||||
str(silent_video_path),
|
||||
],
|
||||
timeout=300,
|
||||
)
|
||||
res_frame_resized = cv2.resize(_out_bgr, (_fw, _fh),
|
||||
interpolation=cv2.INTER_LANCZOS4)
|
||||
del _face_t, _out, _out_bgr, _face_original_bgr
|
||||
except Exception as _gfpgan_err:
|
||||
logger.warning("GFPGAN 增强失败(帧 %d),使用原图: %s", i, _gfpgan_err)
|
||||
|
||||
# 将无声视频复制到输出路径
|
||||
shutil.copy2(str(silent_video_path), str(output_path))
|
||||
# face parsing 融合
|
||||
try:
|
||||
if face_parsing is not None:
|
||||
combined = get_image(ori_frame, res_frame_resized,
|
||||
[x1,y1,x2,y2], fp=face_parsing)
|
||||
else:
|
||||
combined = get_image(ori_frame, res_frame_resized, [x1,y1,x2,y2])
|
||||
except Exception:
|
||||
combined = ori_frame.copy()
|
||||
try: combined[y1:y2, x1:x2] = res_frame_resized
|
||||
except Exception: combined = ori_frame
|
||||
|
||||
_ff_proc.stdin.write(combined.tobytes())
|
||||
|
||||
_ff_proc.stdin.close()
|
||||
_ff_ret = _ff_proc.wait(timeout=120)
|
||||
# 从全局列表中移除已完成的 ffmpeg 进程
|
||||
with _active_ffmpeg_lock:
|
||||
try:
|
||||
_active_ffmpeg_procs.remove(_ff_proc)
|
||||
except ValueError:
|
||||
pass
|
||||
if _ff_ret != 0:
|
||||
_ff_err = _ff_proc.stderr.read().decode(errors="ignore") if _ff_proc.stderr else ""
|
||||
raise RuntimeError(f"ffmpeg编码失败(exit={_ff_ret}): {_ff_err[-300:]}")
|
||||
except Exception:
|
||||
try: _ff_proc.kill()
|
||||
except Exception: pass
|
||||
with _active_ffmpeg_lock:
|
||||
try:
|
||||
_active_ffmpeg_procs.remove(_ff_proc)
|
||||
except ValueError:
|
||||
pass
|
||||
raise
|
||||
|
||||
# ── Step 8: 高帧率视频处理 ──
|
||||
# 对口型数字人视频,25fps 完全够用。
|
||||
# MCI 运动补偿插帧极其耗时(343帧>6分钟),已移除。
|
||||
# 直接输出 25fps 推理结果,后续封装音频后播放器自动适配帧率。
|
||||
final_video = silent_video_path
|
||||
if video_downsampled:
|
||||
logger.info("输入视频 %.1f fps,降帧至 %.1f fps 推理后直接输出(不做插帧还原)", original_fps, inference_fps)
|
||||
|
||||
_mux_video_with_audio(final_video, audio_path, output_path)
|
||||
|
||||
# 清理中间文件
|
||||
try:
|
||||
shutil.rmtree(str(output_frames_dir))
|
||||
torch.cuda.empty_cache()
|
||||
if audio_wav_path.exists():
|
||||
audio_wav_path.unlink()
|
||||
if silent_video_path.exists() and str(silent_video_path) != str(output_path):
|
||||
silent_video_path.unlink()
|
||||
# 清理中间文件
|
||||
for _tmp in [silent_video_path] + ([video_path] if video_downsampled else []):
|
||||
if _tmp.exists() and str(_tmp) != str(output_path):
|
||||
_tmp.unlink()
|
||||
except Exception as e:
|
||||
logger.warning("清理中间文件失败: %s", e)
|
||||
|
||||
logger.info("MuseTalk 推理完成: output=%s, duration=%.2fs",
|
||||
output_path.name, _get_media_duration(output_path))
|
||||
logger.info("MuseTalk 推理完成: output=%s, duration=%.2fs, input_fps=%.1f, inference_fps=%.1f",
|
||||
output_path.name, _get_media_duration(output_path), original_fps, inference_fps)
|
||||
|
||||
|
||||
# ── 路由 ──────────────────────────────────────────────────────────────
|
||||
@@ -755,6 +1030,8 @@ def health():
|
||||
"current_task": task_info,
|
||||
"musetalk_loaded": _muse_models_loaded,
|
||||
"musetalk_load_error": str(_muse_load_error) if _muse_load_error else None,
|
||||
"gfpgan_loaded": _gfpgan_loaded,
|
||||
"gfpgan_load_error": _gfpgan_load_error,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
)
|
||||
@@ -813,24 +1090,52 @@ def inference():
|
||||
current_task["start_time"] = time.time()
|
||||
current_task["process"] = "inference_thread" # 标记为运行中
|
||||
|
||||
# 在线程中运行推理(支持超时)
|
||||
# 在线程中运行推理(支持超时 + 终止信号)
|
||||
result_container = {"error": None}
|
||||
abort_event = threading.Event()
|
||||
global current_abort_event, current_thread
|
||||
current_abort_event = abort_event
|
||||
|
||||
def inference_thread():
|
||||
try:
|
||||
_run_inference(video_path, audio_path, output_path, bbox_shift=bbox_shift)
|
||||
_run_inference(video_path, audio_path, output_path, bbox_shift=bbox_shift, abort_event=abort_event)
|
||||
except Exception as exc:
|
||||
logger.exception("推理异常: %s", exc)
|
||||
result_container["error"] = str(exc)
|
||||
|
||||
thread = threading.Thread(target=inference_thread)
|
||||
thread = threading.Thread(target=inference_thread, daemon=True)
|
||||
current_thread = thread
|
||||
thread.start()
|
||||
thread.join(timeout=Config.inference_timeout)
|
||||
|
||||
# 动态超时:基础 120s + 预估帧数×0.15s,上限 1800s
|
||||
video_fps = _get_video_fps(video_path)
|
||||
video_duration = _get_media_duration(video_path)
|
||||
estimated_frames = int(video_duration * video_fps) if video_duration > 0 else 500
|
||||
dynamic_timeout = min(max(Config.inference_timeout, 120 + estimated_frames * 0.15), 1800)
|
||||
|
||||
thread.join(timeout=dynamic_timeout)
|
||||
|
||||
if thread.is_alive():
|
||||
# 超时,终止
|
||||
logger.error("推理超时 (>%ds),终止任务 %s", Config.inference_timeout, task_id)
|
||||
return jsonify({"error": f"推理超时(>{Config.inference_timeout}s)", "task_id": task_id}), 504
|
||||
# 超时处理:发终止信号 + 杀 ffmpeg 子进程 + 等待线程退出
|
||||
logger.error("推理超时 (>%ds),终止任务 %s", dynamic_timeout, task_id)
|
||||
abort_event.set()
|
||||
# 终止所有正在运行的 ffmpeg 子进程
|
||||
with _active_ffmpeg_lock:
|
||||
for proc in _active_ffmpeg_procs[:]:
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
_active_ffmpeg_procs.clear()
|
||||
# 等待线程退出(daemon 线程会在主进程退出时自动终止,但这里给一定时间让它清理)
|
||||
thread.join(timeout=10)
|
||||
# 标记超时错误,由 finally 统一清理
|
||||
result_container["error"] = f"推理超时(>{dynamic_timeout:.0f}s)"
|
||||
result_container["timeout"] = True
|
||||
|
||||
if result_container.get("timeout"):
|
||||
return jsonify({"error": result_container["error"], "task_id": task_id}), 504
|
||||
|
||||
if result_container["error"]:
|
||||
logger.error("推理失败 task_id=%s: %s", task_id, result_container["error"])
|
||||
@@ -850,6 +1155,8 @@ def inference():
|
||||
current_task["task_id"] = None
|
||||
current_task["process"] = None
|
||||
current_task["start_time"] = 0.0
|
||||
current_abort_event = None
|
||||
current_thread = None
|
||||
|
||||
# 清理临时文件
|
||||
if video_path and video_path.parent.exists():
|
||||
@@ -863,20 +1170,35 @@ def inference():
|
||||
@app.route("/cancel", methods=["POST"])
|
||||
def cancel():
|
||||
"""终止当前正在进行的推理任务."""
|
||||
global current_abort_event, current_thread
|
||||
|
||||
if current_task["task_id"] is None:
|
||||
return jsonify({"message": "当前无正在运行的任务"})
|
||||
|
||||
task_id = current_task["task_id"]
|
||||
logger.info("收到取消请求,终止任务 %s", task_id)
|
||||
|
||||
# 终止推理进程(如果是 subprocess)
|
||||
if current_task["process"] and current_task["process"] != "inference_thread":
|
||||
try:
|
||||
current_task["process"].terminate()
|
||||
current_task["process"].wait(timeout=5)
|
||||
logger.info("已终止推理进程")
|
||||
except Exception as exc:
|
||||
logger.warning("终止进程失败: %s", exc)
|
||||
# 发送终止信号给推理线程
|
||||
if current_abort_event is not None:
|
||||
current_abort_event.set()
|
||||
logger.info("已发送终止信号给推理线程")
|
||||
|
||||
# 终止所有正在运行的 ffmpeg 子进程
|
||||
with _active_ffmpeg_lock:
|
||||
for proc in _active_ffmpeg_procs[:]:
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
logger.info("已终止 ffmpeg 子进程")
|
||||
except Exception as exc:
|
||||
logger.warning("终止 ffmpeg 子进程失败: %s", exc)
|
||||
_active_ffmpeg_procs.clear()
|
||||
|
||||
# 等待推理线程退出(daemon 线程会在主进程退出时自动终止)
|
||||
if current_thread is not None and current_thread.is_alive():
|
||||
current_thread.join(timeout=10)
|
||||
if current_thread.is_alive():
|
||||
logger.warning("推理线程未能在 10s 内退出,将作为 daemon 线程自动终止")
|
||||
|
||||
# 清理临时文件
|
||||
task_dir = Path(Config.temp_dir) / task_id
|
||||
@@ -891,6 +1213,8 @@ def cancel():
|
||||
current_task["task_id"] = None
|
||||
current_task["process"] = None
|
||||
current_task["start_time"] = 0.0
|
||||
current_abort_event = None
|
||||
current_thread = None
|
||||
|
||||
return jsonify({"message": f"已取消任务 {task_id}"})
|
||||
|
||||
@@ -899,6 +1223,8 @@ def cancel():
|
||||
|
||||
|
||||
def main():
|
||||
import os as _os
|
||||
_os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "max_split_size_mb:128")
|
||||
"""启动 Flask 服务."""
|
||||
# 创建临时目录
|
||||
Path(Config.temp_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,10 @@ def build_engine(
|
||||
pool_timeout: int = 30,
|
||||
pool_recycle: int = 3600,
|
||||
):
|
||||
# SQLite 不支持 QueuePool 的 pool_size/max_overflow/pool_timeout,
|
||||
# 传了会在 create_engine 阶段直接 TypeError,这里只对非 SQLite 传连接池参数。
|
||||
if _is_sqlite(database_url):
|
||||
return create_engine(database_url, pool_recycle=pool_recycle)
|
||||
return create_engine(
|
||||
database_url,
|
||||
pool_size=pool_size,
|
||||
|
||||
@@ -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",
|
||||
@@ -71,28 +102,6 @@ EMOTION_MAP: dict[str, str] = {
|
||||
"friendly": "happy",
|
||||
}
|
||||
|
||||
# ── Style(语气风格)→ Instruct 指令映射(CosyVoice v3 Instruct 模式)──
|
||||
# 前端传 6 种 style(natural/excited/professional/gentle/news/livestream),
|
||||
# 映射为中文自然语言指令,写入 CosyVoice input.instruction 字段。
|
||||
# 克隆音色直接使用中文自然语言;系统音色(严格格式)需转换为兼容的 instruct。
|
||||
STYLE_INSTRUCTION_MAP: dict[str, str] = {
|
||||
"natural": "用自然、平和的语气说话。",
|
||||
"excited": "用兴奋、激动的语气说话。",
|
||||
"professional": "用专业、正式的语气说话。",
|
||||
"gentle": "用温柔、柔和的语气说话。",
|
||||
"news": "用新闻播报的语气说话。",
|
||||
"livestream": "用直播解说的语气说话。",
|
||||
}
|
||||
|
||||
# 严格格式系统音色:style → emotion 映射(用于无法使用自由文本指令的音色)
|
||||
_STYLE_TO_EMOTION: dict[str, str] = {
|
||||
"natural": "neutral",
|
||||
"excited": "happy",
|
||||
"professional": "neutral",
|
||||
"gentle": "neutral",
|
||||
# news / livestream 无直接对应 emotion,由 build_style_instruction 特殊处理
|
||||
}
|
||||
|
||||
|
||||
# ── 支持 emotion Instruct 的 v3-flash 系统音色白名单(官方音色列表标注"Instruct:支持"且支持情感值)──
|
||||
# 这些音色的 instruction 必须使用中文固定格式 "你说话的情感是<emotion>。";
|
||||
@@ -144,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.
|
||||
|
||||
@@ -170,45 +243,6 @@ def normalize_emotion(emotion: str) -> str:
|
||||
return "neutral"
|
||||
|
||||
|
||||
def build_style_instruction(voice_id: str, style: str) -> str:
|
||||
"""根据 voice 类型构造 style instruction.
|
||||
|
||||
- 克隆音色:直接使用中文自然语言指令(CosyVoice 对克隆音色支持任意自然语言)
|
||||
- 系统音色(emotion-instruct 白名单内):转换为兼容的 instruct 格式
|
||||
* excited → "你说话的情感是happy。"
|
||||
* news → "你正在进行新闻播报,你说话的情感是neutral。"
|
||||
* 其他无直接对应 → 返回空串(不传 instruction)
|
||||
- 其他系统音色(不支持 Instruct):返回空串
|
||||
|
||||
Args:
|
||||
voice_id: CosyVoice voice 参数
|
||||
style: 风格值(natural/excited/professional/gentle/news/livestream)
|
||||
|
||||
Returns:
|
||||
instruction 字符串;不支持时返回空串
|
||||
"""
|
||||
if not style or style == "natural":
|
||||
# natural 是默认风格,不额外添加 instruct
|
||||
return ""
|
||||
description = STYLE_INSTRUCTION_MAP.get(style, "")
|
||||
if not description:
|
||||
logger.warning("未知的 style 值 %r,跳过 style instruction", style)
|
||||
return ""
|
||||
# 克隆音色:直接使用中文自然语言
|
||||
if _is_cloned_voice(voice_id):
|
||||
return description
|
||||
# 严格格式系统音色(white-listed):转换为兼容格式
|
||||
if voice_id in _SYSTEM_VOICES_WITH_EMOTION_INSTRUCT:
|
||||
if style == "news":
|
||||
return "你正在进行新闻播报,你说话的情感是neutral。"
|
||||
emotion_val = _STYLE_TO_EMOTION.get(style)
|
||||
if emotion_val:
|
||||
return f"你说话的情感是{emotion_val}。"
|
||||
return ""
|
||||
# 其他系统音色:不支持 Instruct,返回空串
|
||||
return ""
|
||||
|
||||
|
||||
# 系统音色仅支持中文/英文(language_hints 取值)
|
||||
_SYSTEM_VOICE_LANGS = {"zh", "en"}
|
||||
|
||||
@@ -649,14 +683,11 @@ 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
|
||||
language: 语言代码(zh/en 等,默认 zh;系统音色仅 zh/en 传 language_hints)
|
||||
style: 语气风格(natural/excited/professional/gentle/news/livestream),
|
||||
可选参数,用于 CosyVoice v3 Instruct 模式;
|
||||
style 与 emotion 互斥,style 优先级高于 emotion
|
||||
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:
|
||||
dict: {"audio_url": str, "request_id": str,
|
||||
@@ -684,16 +715,16 @@ class CosyVoiceService:
|
||||
"rate": speed,
|
||||
"volume": volume,
|
||||
}
|
||||
# pitch: CosyVoice API 支持 [0.5, 2.0],1.0 为默认值,非默认时才传
|
||||
if pitch != 1.0:
|
||||
# pitch: CosyVoice API 支持 [0.5, 2.0],非默认值时才传
|
||||
if pitch and pitch != 1.0:
|
||||
input_payload["pitch"] = pitch
|
||||
# style 优先级高于 emotion:style 是新的 Instruct 模式参数,
|
||||
# 前端传 style 时不再走旧的 emotion 逻辑
|
||||
# style 优先:显式传 style 时走 build_style_instruction();
|
||||
# 无 style 时回退到旧的 emotion → build_emotion_instruction() 逻辑(向后兼容)
|
||||
s = (style or "").strip().lower()
|
||||
instruction = ""
|
||||
if style:
|
||||
instruction = build_style_instruction(voice_id, style)
|
||||
if s:
|
||||
instruction = build_style_instruction(voice_id, s)
|
||||
if not instruction:
|
||||
# 回退到旧的 emotion → instruction 逻辑
|
||||
norm_emotion = normalize_emotion(emotion)
|
||||
instruction = build_emotion_instruction(voice_id, norm_emotion)
|
||||
if instruction:
|
||||
@@ -757,6 +788,7 @@ class CosyVoiceService:
|
||||
volume: int = 50,
|
||||
emotion: str = "",
|
||||
language: str = "zh",
|
||||
timeout: float = 120.0,
|
||||
style: str = "",
|
||||
pitch: float = 1.0,
|
||||
) -> SynthesizeResult:
|
||||
@@ -772,7 +804,7 @@ class CosyVoiceService:
|
||||
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
|
||||
speed: 语速(0.5-2.0),1.0 为正常速度
|
||||
volume: 音量(0-100),默认 50
|
||||
style: 语气风格(natural/excited/professional/gentle/news/livestream)
|
||||
style: 语气风格(natural/sweet/excited/professional/news/livestream)
|
||||
pitch: 音调(0.5-2.0),1.0 为默认值
|
||||
timeout: 超时时间(秒),保留参数兼容
|
||||
|
||||
|
||||
@@ -145,22 +145,22 @@ class TTSWorkflowService:
|
||||
try:
|
||||
_meta = dict(job.metadata)
|
||||
_speed = float(_meta.get("speed", 1.0) or 1.0)
|
||||
_volume = int(_meta.get("volume", 50) or 50)
|
||||
_emotion = str(_meta.get("emotion", "") or "")
|
||||
_style = str(_meta.get("style", "") or "")
|
||||
_language = str(_meta.get("language", "zh-CN") or "zh-CN")
|
||||
_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,
|
||||
voice_id=job.voice_id,
|
||||
sample_rate=job.sample_rate,
|
||||
format=job.format,
|
||||
speed=_speed,
|
||||
style=_style,
|
||||
volume=_volume,
|
||||
pitch=_pitch,
|
||||
emotion=_emotion,
|
||||
language=_language,
|
||||
style=_style,
|
||||
pitch=_pitch,
|
||||
)
|
||||
|
||||
# 保存 task_id / request_id 到 metadata
|
||||
@@ -296,10 +296,10 @@ class TTSWorkflowService:
|
||||
job_metadata = job.metadata or {}
|
||||
speed = float(job_metadata.get("speed", 1.0))
|
||||
volume = int(job_metadata.get("volume", 50))
|
||||
emotion = str(job_metadata.get("emotion", "") or "")
|
||||
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")
|
||||
pitch = float(job_metadata.get("pitch", 1.0) or 1.0)
|
||||
|
||||
result = self.cosyvoice_service.submit_synthesize_task(
|
||||
text=job.input_text,
|
||||
@@ -307,11 +307,11 @@ class TTSWorkflowService:
|
||||
sample_rate=job.sample_rate,
|
||||
format=job.format,
|
||||
speed=speed,
|
||||
style=style,
|
||||
volume=volume,
|
||||
pitch=pitch,
|
||||
emotion=emotion,
|
||||
language=language,
|
||||
style=style,
|
||||
pitch=pitch,
|
||||
)
|
||||
audio_url = result.get("audio_url", "")
|
||||
if not audio_url:
|
||||
@@ -424,9 +424,9 @@ class TTSWorkflowService:
|
||||
results: list[dict | None] = [None] * len(segments)
|
||||
_seg_meta = job.metadata or {}
|
||||
_seg_speed = float(_seg_meta.get("speed", 1.0) or 1.0)
|
||||
_seg_volume = int(_seg_meta.get("volume", 50) or 50)
|
||||
_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:
|
||||
@@ -439,11 +439,11 @@ 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",
|
||||
style=_seg_style,
|
||||
pitch=_seg_pitch,
|
||||
)
|
||||
future_to_idx[future] = idx
|
||||
|
||||
@@ -533,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 "")
|
||||
|
||||
# 分段文本(用于缺失段重新合成)
|
||||
@@ -567,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",
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -68,7 +68,9 @@ def test_register_payload_includes_task_id_only_when_provided(worker, monkeypatc
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
def json(self):
|
||||
return {"worker_id": captured[-1]["worker_id"], "cancel_task": False}
|
||||
|
||||
def _fake_post(url, json=None, headers=None, timeout=None):
|
||||
captured.append(json)
|
||||
@@ -77,7 +79,9 @@ def test_register_payload_includes_task_id_only_when_provided(worker, monkeypatc
|
||||
monkeypatch.setattr(worker.requests, "post", _fake_post)
|
||||
monkeypatch.setattr(worker, "_check_musetalk_health", lambda: (True, {}))
|
||||
|
||||
assert worker._register("task-abc") is True
|
||||
_ok, _cancel = worker._register("task-abc")
|
||||
assert _ok is True
|
||||
assert _cancel is False
|
||||
assert captured[-1]["task_id"] == "task-abc"
|
||||
assert captured[-1]["worker_id"]
|
||||
|
||||
@@ -93,7 +97,7 @@ def test_task_heartbeat_thread_sends_and_stops(worker, monkeypatch):
|
||||
|
||||
def _fake_register(task_id=None):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
return True, False
|
||||
|
||||
monkeypatch.setattr(worker, "_register", _fake_register)
|
||||
hb = worker.TaskHeartbeat("task-hb1", interval=5)
|
||||
@@ -105,6 +109,50 @@ def test_task_heartbeat_thread_sends_and_stops(worker, monkeypatch):
|
||||
assert calls and all(c == "task-hb1" for c in calls)
|
||||
|
||||
|
||||
def test_task_heartbeat_cancel_calls_musetalk_cancel(worker, monkeypatch):
|
||||
"""心跳响应 cancel_task=True → 调 _cancel_musetalk 并设置 cancelled 标志。"""
|
||||
cancel_calls = []
|
||||
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: (True, True))
|
||||
monkeypatch.setattr(worker, "_cancel_musetalk", lambda: cancel_calls.append(1))
|
||||
|
||||
hb = worker.TaskHeartbeat("task-cancel-1", interval=5)
|
||||
hb.start()
|
||||
hb.join(timeout=2) # 检测到取消后线程自行 return
|
||||
assert not hb.is_alive()
|
||||
assert hb.cancelled is True
|
||||
assert cancel_calls == [1]
|
||||
|
||||
|
||||
def test_handle_task_reports_cancelled_after_musetalk_abort(worker, monkeypatch):
|
||||
"""推理被 /cancel 终止后,hb.cancelled=True → 上报失败而非重试。"""
|
||||
reports = []
|
||||
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: (True, False))
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 12.0)
|
||||
# 模拟推理被终止(/inference 返回错误)
|
||||
monkeypatch.setattr(worker, "_call_musetalk", lambda v, a, o: (False, 0.0, "推理被取消", False))
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_report_result",
|
||||
lambda task_id, success, duration=0.0, error_msg="": reports.append(error_msg) or True,
|
||||
)
|
||||
|
||||
# 让 TaskHeartbeat 在主线程检查时报告已取消
|
||||
orig_hb_init = worker.TaskHeartbeat
|
||||
|
||||
def _hb(task_id, interval):
|
||||
h = orig_hb_init(task_id, interval)
|
||||
h.cancelled = True
|
||||
return h
|
||||
|
||||
monkeypatch.setattr(worker, "TaskHeartbeat", _hb)
|
||||
|
||||
worker._handle_task({"task_id": "t-canceled", "video_url": "u", "audio_url": "u"})
|
||||
assert reports == ["用户取消任务"]
|
||||
|
||||
|
||||
# ── 短视频前置拦截 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -115,7 +163,7 @@ def test_handle_task_short_video_reports_failed_without_inference(worker, monkey
|
||||
audio.write_bytes(b"fake-audio")
|
||||
reports = []
|
||||
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: (True, False))
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
# ffprobe 读出 1.2s → 低于 3s 阈值
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 1.2)
|
||||
@@ -148,7 +196,7 @@ def test_handle_task_short_video_reports_failed_without_inference(worker, monkey
|
||||
def test_handle_task_probe_failure_does_not_block(worker, monkeypatch):
|
||||
"""ffprobe 不可用(duration=0.0)时不能误杀,应继续推理."""
|
||||
reports = []
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: (True, False))
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 0.0)
|
||||
monkeypatch.setattr(
|
||||
@@ -214,7 +262,7 @@ def test_handle_task_retries_once_for_transient_then_succeeds(worker, monkeypatc
|
||||
return False, 0.0, "MuseTalk HTTP 503: busy", True
|
||||
return True, 6.5, "", False
|
||||
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: (True, False))
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 12.0)
|
||||
monkeypatch.setattr(worker, "_call_musetalk", _fake_call)
|
||||
@@ -239,7 +287,7 @@ def test_handle_task_no_retry_for_deterministic_failure(worker, monkeypatch):
|
||||
return False, 0.0, "MuseTalk HTTP 400: bad input", False
|
||||
|
||||
reports = []
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: (True, False))
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 12.0)
|
||||
monkeypatch.setattr(worker, "_call_musetalk", _fake_call)
|
||||
|
||||
@@ -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,89 @@
|
||||
"""GPU Worker 路由单测 — #2009 取消链路.
|
||||
|
||||
直接调用路由函数(不经 HTTP 栈),显式注入 svc / _token 以跳过 Depends。
|
||||
CI 增量映射: gpu_lipsync.py (route) → test_gpu_lipsync_routes.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
from app.schemas.gpu_lipsync import GpuWorkerRegisterRequest
|
||||
|
||||
data = {
|
||||
"worker_id": "w-1",
|
||||
"hostname": "gpu-host",
|
||||
"gpu_name": "RTX3060",
|
||||
"free_vram_mb": 10000,
|
||||
"capabilities": json.dumps({"musetalk": True}),
|
||||
}
|
||||
data.update(overrides)
|
||||
return GpuWorkerRegisterRequest(**data)
|
||||
|
||||
|
||||
def test_register_returns_cancel_task_true_when_cancelled():
|
||||
"""心跳接口在任务已取消时必须把 cancel_task=True 透传给 Worker."""
|
||||
fake_worker = MagicMock()
|
||||
fake_worker.worker_id = "w-1"
|
||||
fake_worker.hostname = "gpu-host"
|
||||
fake_worker.gpu_name = "RTX3060"
|
||||
fake_worker.free_vram_mb = 10000
|
||||
fake_worker.capabilities = "musetalk"
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.register_worker.return_value = (fake_worker, True)
|
||||
|
||||
from app.api.routes.gpu_lipsync import register_worker as route
|
||||
|
||||
resp = route(_payload(task_id="task-cancelled"), svc=fake_svc, _token="t")
|
||||
|
||||
assert resp.cancel_task is True
|
||||
assert resp.ok is True
|
||||
fake_svc.register_worker.assert_called_once()
|
||||
kwargs = fake_svc.register_worker.call_args.kwargs
|
||||
assert kwargs["task_id"] == "task-cancelled"
|
||||
|
||||
|
||||
def test_register_returns_cancel_task_false_normal():
|
||||
"""正常心跳 cancel_task=False."""
|
||||
fake_worker = MagicMock()
|
||||
fake_worker.worker_id = "w-1"
|
||||
fake_worker.hostname = "gpu-host"
|
||||
fake_worker.gpu_name = "RTX3060"
|
||||
fake_worker.free_vram_mb = 10000
|
||||
fake_worker.capabilities = "musetalk"
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.register_worker.return_value = (fake_worker, False)
|
||||
|
||||
from app.api.routes.gpu_lipsync import register_worker as route
|
||||
|
||||
resp = route(_payload(), svc=fake_svc, _token="t")
|
||||
|
||||
assert resp.cancel_task is False
|
||||
|
||||
|
||||
def test_cancel_route_accepts_processing_status():
|
||||
"""cancel 路由允许 processing 状态(GPU 推理中),不再 400。"""
|
||||
fake_job = MagicMock()
|
||||
fake_job.status = "cancelled"
|
||||
|
||||
svc = MagicMock()
|
||||
svc.cancel_job.return_value = fake_job
|
||||
|
||||
current_user = MagicMock()
|
||||
current_user.user.id = "u1"
|
||||
|
||||
from app.api.routes.lipsync import cancel_lipsync_job as route
|
||||
|
||||
result = route("job-1", current_user, svc)
|
||||
|
||||
svc.cancel_job.assert_called_once_with("job-1", "u1")
|
||||
assert result.status == "cancelled"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-q"])
|
||||
@@ -173,10 +173,10 @@ def test_timed_out_task_is_redispatched(svc):
|
||||
|
||||
|
||||
def test_register_worker_creates_then_updates(svc):
|
||||
w = svc.register_worker("w-1", hostname="pc1", gpu_name="RTX2060", free_vram_mb=3500)
|
||||
w, _cancel = svc.register_worker("w-1", hostname="pc1", gpu_name="RTX2060", free_vram_mb=3500)
|
||||
assert w.worker_id == "w-1"
|
||||
assert w.gpu_name == "RTX2060"
|
||||
w2 = svc.register_worker("w-1", hostname="pc1", gpu_name="RTX2060", free_vram_mb=2000)
|
||||
w2, _cancel2 = svc.register_worker("w-1", hostname="pc1", gpu_name="RTX2060", free_vram_mb=2000)
|
||||
assert w2.free_vram_mb == 2000 # 更新
|
||||
assert w2.created_at == w.created_at # 没新建
|
||||
|
||||
@@ -195,7 +195,7 @@ def test_register_with_task_id_refreshes_task_heartbeat(svc):
|
||||
{"last_heartbeat_at": old_hb - timedelta(seconds=300)}
|
||||
)
|
||||
svc.db.commit()
|
||||
svc.register_worker("w-1", task_id=t.id)
|
||||
_w, _c = svc.register_worker("w-1", task_id=t.id)
|
||||
svc.db.refresh(t)
|
||||
assert t.last_heartbeat_at > old_hb
|
||||
assert t.status == "processing" # 心跳不改变状态
|
||||
@@ -213,7 +213,7 @@ def test_register_task_heartbeat_ignores_finished_or_foreign_task(svc):
|
||||
svc.poll_task("w-1")
|
||||
done = svc.report_result(t.id, "w-1", success=True, duration_seconds=10.0)
|
||||
hb_when_done = done.last_heartbeat_at
|
||||
svc.register_worker("w-1", task_id=t.id)
|
||||
_w, _c = svc.register_worker("w-1", task_id=t.id)
|
||||
svc.db.refresh(t)
|
||||
assert t.status == "done"
|
||||
assert t.last_heartbeat_at == hb_when_done # 没被改写
|
||||
@@ -232,17 +232,61 @@ def test_register_task_heartbeat_ignores_finished_or_foreign_task(svc):
|
||||
{"last_heartbeat_at": owner_hb - timedelta(seconds=600)}
|
||||
)
|
||||
svc.db.commit()
|
||||
svc.register_worker("w-1", task_id=t2.id) # 旧 worker 迟到心跳
|
||||
_w2, _c2 = svc.register_worker("w-1", task_id=t2.id) # 旧 worker 迟到心跳
|
||||
svc.db.refresh(t2)
|
||||
assert t2.worker_id == "w-2"
|
||||
assert t2.status == "processing"
|
||||
assert t2.last_heartbeat_at == owner_hb
|
||||
|
||||
# 场景 3:不存在的 task_id 不报错
|
||||
svc.register_worker("w-1", task_id="nonexistent-id")
|
||||
_wn, _cn = svc.register_worker("w-1", task_id="nonexistent-id")
|
||||
assert svc.db.get(GpuLipsyncTaskModel, "nonexistent-id") is None
|
||||
|
||||
|
||||
def test_register_task_heartbeat_detects_cancelled(svc):
|
||||
"""取消链路:任务已 cancelled 时,register 心跳必须返回 cancel_task=True."""
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
svc.poll_task("w-1")
|
||||
svc.db.refresh(t)
|
||||
# 用户取消:直接把任务置为 cancelled
|
||||
t.status = "cancelled"
|
||||
t.finished_at = datetime.now(UTC)
|
||||
svc.db.commit()
|
||||
|
||||
_w, cancel_task = svc.register_worker("w-1", task_id=t.id)
|
||||
assert cancel_task is True
|
||||
svc.db.refresh(t)
|
||||
assert t.status == "cancelled" # 心跳不改写已取消状态
|
||||
|
||||
|
||||
def test_report_result_cancelled_stays_cancelled(svc):
|
||||
"""Worker 终止取消任务后上报失败,report_result 必须保持 cancelled 不回退 pending."""
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
svc.poll_task("w-1")
|
||||
svc.db.refresh(t)
|
||||
t.status = "cancelled"
|
||||
svc.db.commit()
|
||||
|
||||
result = svc.report_result(t.id, "w-1", success=False, error_msg="推理被终止")
|
||||
assert result.status == "cancelled"
|
||||
assert result.finished_at is not None
|
||||
assert "推理被终止" in (result.error_msg or "")
|
||||
|
||||
|
||||
def test_wait_for_result_returns_when_cancelled(svc):
|
||||
"""wait_for_result 将 cancelled 视为终态,立即返回,Celery 不回退 MediaKit."""
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
svc.poll_task("w-1")
|
||||
svc.db.refresh(t)
|
||||
t.status = "cancelled"
|
||||
t.finished_at = datetime.now(UTC)
|
||||
svc.db.commit()
|
||||
|
||||
result = svc.wait_for_result(t.id, timeout_seconds=5, poll_interval=0.1)
|
||||
assert result is not None
|
||||
assert result.status == "cancelled"
|
||||
|
||||
|
||||
def test_default_gpu_task_timeout_is_900(svc):
|
||||
"""#1970 默认超时 300→900,覆盖 RTX2060 长视频推理."""
|
||||
assert svc.settings.gpu_task_timeout_seconds == 900
|
||||
|
||||
@@ -248,3 +248,41 @@ class TestSignMediaUrl:
|
||||
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
|
||||
|
||||
|
||||
def test_cancelled_gpu_task_does_not_fallback_mediakit(monkeypatch):
|
||||
"""GPU 任务被用户取消 → Celery 任务直接标记 cancelled,不回退 MediaKit。"""
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
job.status = "processing"
|
||||
job.mediakit_task_id = "gpu:gpu-task-1"
|
||||
|
||||
gpu_task = MagicMock()
|
||||
gpu_task.status = "cancelled"
|
||||
gpu_task.error_msg = "用户取消"
|
||||
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.wait_for_result.return_value = gpu_task
|
||||
gpu_service_cls = MagicMock(return_value=fake_gpu_svc)
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_db.query.return_value.filter_by.return_value.first.return_value = job
|
||||
|
||||
# 直接替换 sys.modules 里的 gpu_lipsync_service 模块(全量跑时它可能已被
|
||||
# 其他测试换成 MagicMock),保证任务函数内 from...import 一定拿到我们的类;
|
||||
# 并替换 _get_db_session 绕开 worker_app / app.db 两条 import 分支。
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
fake_mod = SimpleNamespace(GpuLipsyncService=gpu_service_cls)
|
||||
monkeypatch.setitem(sys.modules, "app.services.gpu_lipsync_service", fake_mod)
|
||||
monkeypatch.setattr(task_mod, "_get_db_session", lambda: fake_db)
|
||||
monkeypatch.setattr(task_mod, "logger", MagicMock())
|
||||
|
||||
task_mod.lipsync_gpu_process_async.run("job-1", "u1", "gpu-task-1")
|
||||
|
||||
assert job.status == "cancelled"
|
||||
assert not str(job.mediakit_task_id).startswith("mk-")
|
||||
fake_db.commit.assert_called()
|
||||
fake_gpu_svc.wait_for_result.assert_called_once()
|
||||
gpu_service_cls.assert_called_once_with(fake_db)
|
||||
|
||||
@@ -323,3 +323,126 @@ class TestGpuServiceHelpers:
|
||||
svc = GpuLipsyncService(db=fake_db)
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = None
|
||||
assert svc.has_available_worker() is False
|
||||
|
||||
|
||||
# ── cancel_job 取消链路 (#2009) ─────────────────────────────────────
|
||||
|
||||
|
||||
def _build_sqlite_session():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import models as _ # noqa: F401
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine, future=True)
|
||||
return Session()
|
||||
|
||||
|
||||
def _make_real_job(db, *, status="processing", mediakit_task_id="gpu:gpu-task-1"):
|
||||
import uuid
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
|
||||
job = LipsyncJobModel(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id="u1",
|
||||
project_id="p1",
|
||||
video_url="videos/v.mp4",
|
||||
audio_url="audios/a.wav",
|
||||
enable_video_loop=True,
|
||||
mediakit_task_id=mediakit_task_id,
|
||||
status=status,
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
return job
|
||||
|
||||
|
||||
def test_cancel_processing_gpu_job_marks_gpu_task_cancelled():
|
||||
"""processing 的 GPU job 取消时,关联 GpuLipsyncTask 必须同步置 cancelled."""
|
||||
import uuid
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel
|
||||
|
||||
db = _build_sqlite_session()
|
||||
gpu_task_id = str(uuid.uuid4())
|
||||
gpu_task = GpuLipsyncTaskModel(
|
||||
id=gpu_task_id,
|
||||
video_url="v",
|
||||
audio_url="a",
|
||||
status="processing",
|
||||
worker_id="w-1",
|
||||
attempt=1,
|
||||
)
|
||||
db.add(gpu_task)
|
||||
db.commit()
|
||||
|
||||
job = _make_real_job(db, mediakit_task_id=f"gpu:{gpu_task_id}")
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
svc = LipsyncService(db=db, client=MagicMock())
|
||||
result = svc.cancel_job(job.id, "u1")
|
||||
|
||||
assert result.status == "cancelled"
|
||||
db.refresh(gpu_task)
|
||||
assert gpu_task.status == "cancelled"
|
||||
assert gpu_task.error_msg == "用户取消"
|
||||
assert gpu_task.finished_at is not None
|
||||
|
||||
|
||||
def test_cancel_processing_gpu_job_skips_non_processing_gpu_task():
|
||||
"""GPU task 已不在 processing(如已 done)时,取消 job 不应改它,也不报错."""
|
||||
import uuid
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel
|
||||
|
||||
db = _build_sqlite_session()
|
||||
gpu_task_id = str(uuid.uuid4())
|
||||
gpu_task = GpuLipsyncTaskModel(
|
||||
id=gpu_task_id, video_url="v", audio_url="a", status="done", worker_id="w-1", attempt=1
|
||||
)
|
||||
db.add(gpu_task)
|
||||
db.commit()
|
||||
|
||||
job = _make_real_job(db, mediakit_task_id=f"gpu:{gpu_task_id}")
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
svc = LipsyncService(db=db, client=MagicMock())
|
||||
result = svc.cancel_job(job.id, "u1")
|
||||
|
||||
assert result.status == "cancelled"
|
||||
db.refresh(gpu_task)
|
||||
assert gpu_task.status == "done" # 没被动
|
||||
|
||||
|
||||
def test_cancel_processing_non_gpu_job_does_not_touch_gpu_table():
|
||||
"""mediakit_task_id 不是 gpu: 前缀(普通 MediaKit 任务)时,不查 GPU task."""
|
||||
db = _build_sqlite_session()
|
||||
job = _make_real_job(db, mediakit_task_id="mk-task-99")
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
svc = LipsyncService(db=db, client=MagicMock())
|
||||
result = svc.cancel_job(job.id, "u1")
|
||||
assert result.status == "cancelled"
|
||||
|
||||
|
||||
def test_cancel_completed_job_unchanged():
|
||||
"""completed 状态不可取消,cancel_job 原样返回."""
|
||||
db = _build_sqlite_session()
|
||||
job = _make_real_job(db, status="completed", mediakit_task_id="gpu:x")
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
svc = LipsyncService(db=db, client=MagicMock())
|
||||
result = svc.cancel_job(job.id, "u1")
|
||||
assert result.status == "completed"
|
||||
|
||||
|
||||
def test_cancel_job_not_found_returns_none():
|
||||
db = _build_sqlite_session()
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
svc = LipsyncService(db=db, client=MagicMock())
|
||||
assert svc.cancel_job("nonexistent", "u1") is None
|
||||
|
||||
@@ -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)必须被重签."""
|
||||
|
||||
@@ -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