Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f607b0cec9 | |||
| 688b35efa8 | |||
| ce6c831cf3 | |||
| 3267b24433 | |||
| 222c4d15a9 | |||
| 63fb0508be | |||
| 577ec83636 | |||
| 6503a74a7c | |||
| 4a93aaaf4c | |||
| 1a4f475fbf | |||
| 2fa6de29bc | |||
| 831075a9c0 | |||
| a83b53ae58 | |||
| e250132ace | |||
| 774dd27844 | |||
| ed7af0642d | |||
| 938ef0b8cc | |||
| 982daac6e5 |
@@ -0,0 +1,27 @@
|
||||
"""add sentence_timings to lipsync_jobs
|
||||
|
||||
Revision ID: 075_add_sentence_timings
|
||||
Revises: 074_ai_avatar_render_script_id_optional
|
||||
Create Date: 2026-09-12
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "075_add_sentence_timings"
|
||||
down_revision = "074_render_script_id_optional"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("lipsync_jobs") as batch:
|
||||
batch.add_column(
|
||||
sa.Column("sentence_timings", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("lipsync_jobs") as batch:
|
||||
batch.drop_column("sentence_timings")
|
||||
@@ -18,7 +18,6 @@ from app.dependencies import get_db_session
|
||||
from app.schemas.ai_avatar_render import (
|
||||
AiAvatarRenderJobResponse,
|
||||
CreateAiAvatarRenderRequest,
|
||||
SmartCoverRequest,
|
||||
SmartCoverResponse,
|
||||
)
|
||||
from app.services.ai_avatar_cover_service import generate_smart_cover
|
||||
@@ -191,36 +190,42 @@ def retry_render_job(
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
|
||||
|
||||
|
||||
# ── POST /smart-cover — 智能获取封面(MediaKit 抽帧 + 评分选帧)────────
|
||||
# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)────────
|
||||
|
||||
|
||||
@router.post("/smart-cover", response_model=SmartCoverResponse)
|
||||
def generate_avatar_smart_cover(
|
||||
body: SmartCoverRequest,
|
||||
@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse)
|
||||
def generate_render_smart_cover(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> SmartCoverResponse:
|
||||
"""智能获取数字人视频封面.
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS).
|
||||
|
||||
复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧逻辑(非 FFmpeg 简单截帧),
|
||||
并将选中帧转存到自家 OSS,返回非临时的封面公网 URL。
|
||||
|
||||
前端「智能获取封面」按钮可直接调用本接口;不依赖渲染任务完成。
|
||||
- 必须等渲染任务 completed 后才可调用(否则返回 400)
|
||||
- 生成成功后自动更新 render_job 的 cover_config 与 output_cover_url
|
||||
"""
|
||||
video_url = (body.video_url or "").strip()
|
||||
if not video_url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=400, detail="video_url 必须是合法的 HTTP/HTTPS URL")
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
svc = AiAvatarRenderService(db)
|
||||
job = svc.get_render_job(job_id, current_user.user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "completed":
|
||||
raise HTTPException(status_code=400, detail="请先完成视频生成")
|
||||
video_url = (job.output_video_url or "").strip()
|
||||
if not video_url:
|
||||
raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空")
|
||||
|
||||
try:
|
||||
cover_url = generate_smart_cover(
|
||||
video_url,
|
||||
max_frames=body.max_frames,
|
||||
title_config=getattr(body, "title_config", None),
|
||||
)
|
||||
# 从最终成片抽帧,帧本身已含标题/B-roll,直接转存 OSS
|
||||
cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"智能封面生成异常: user=%s video_url=%s err=%s",
|
||||
current_user.user.id, video_url[:80], exc,
|
||||
"渲染成片智能封面生成异常: user=%s render_id=%s video_url=%s err=%s",
|
||||
current_user.user.id,
|
||||
job_id,
|
||||
video_url[:80],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
cover_url = ""
|
||||
@@ -231,5 +236,24 @@ def generate_avatar_smart_cover(
|
||||
status="fallback_failed",
|
||||
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
|
||||
)
|
||||
logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120])
|
||||
|
||||
# 更新 render_job 的封面字段(异步写入 DB;失败不影响返回)
|
||||
try:
|
||||
job.cover_config = {
|
||||
**(job.cover_config if isinstance(job.cover_config, dict) else {}),
|
||||
"mode": "auto_frame",
|
||||
"url": cover_url,
|
||||
}
|
||||
job.output_cover_url = cover_url
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc)
|
||||
|
||||
logger.info(
|
||||
"渲染成片智能封面生成成功: user=%s render_id=%s cover_url=%s",
|
||||
current_user.user.id,
|
||||
job_id,
|
||||
cover_url[:120],
|
||||
)
|
||||
return SmartCoverResponse(cover_url=cover_url, status="completed")
|
||||
|
||||
@@ -52,7 +52,9 @@ class CreateAiAvatarRenderRequest(BaseModel):
|
||||
lipsync_job_id: str = Field(..., description="对口型任务 ID")
|
||||
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_config: dict[str, Any] = Field(
|
||||
default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL)"
|
||||
)
|
||||
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
|
||||
project_id: str = Field("", description="项目 ID")
|
||||
|
||||
@@ -67,7 +69,6 @@ class CreateAiAvatarRenderRequest(BaseModel):
|
||||
@field_validator("script_id")
|
||||
@classmethod
|
||||
def validate_script_id(cls, v: str) -> str:
|
||||
# script_id 可选:手动输入文案(TTS 直生)场景不关联文案库条目
|
||||
return (v or "").strip()
|
||||
|
||||
|
||||
@@ -109,18 +110,8 @@ class AiAvatarRenderProgressResponse(BaseModel):
|
||||
error_message: str
|
||||
|
||||
|
||||
class SmartCoverRequest(BaseModel):
|
||||
"""智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧 + 可选标题 drawtext 叠加."""
|
||||
|
||||
video_url: str = Field(..., description="数字人视频 URL(对口型/渲染成片)")
|
||||
max_frames: int = Field(5, ge=1, le=10, description="抽帧数量(默认 5)")
|
||||
title_config: Optional[dict[str, Any]] = Field(
|
||||
None, description="标题配置;传入时在封面上用 drawtext 叠加标题(竖屏 720x1280)"
|
||||
)
|
||||
|
||||
|
||||
class SmartCoverResponse(BaseModel):
|
||||
"""智能封面响应."""
|
||||
"""智能封面响应(封面从最终成片抽帧,不再叠加标题)."""
|
||||
|
||||
cover_url: str = Field("", description="封面图公网 URL(OSS,非临时);失败为空")
|
||||
status: str = Field("completed", description="completed / fallback_failed")
|
||||
|
||||
@@ -33,6 +33,7 @@ class LipsyncJobResponse(BaseModel):
|
||||
output_duration: float
|
||||
error_message: str
|
||||
error_code: str
|
||||
sentence_timings: Optional[list] = None
|
||||
submitted_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"""AI 数字人封面服务 — 复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧.
|
||||
"""AI 数字人封面服务 — MediaKit 抽帧 + 质量评分选最佳帧 + 转存 OSS.
|
||||
|
||||
与 generation_cover.py 的智能选帧能力对齐(不再用 FFmpeg 简单截帧):
|
||||
1. MediaKit extract_frames 抽取多帧(默认 5 帧,SpecifiedFrames 策略)
|
||||
2. cover_frame_scorer.score_frames 按清晰度/亮度/色彩评分选最佳
|
||||
3. 下载最佳帧并转存 OSS,返回公网封面 URL
|
||||
|
||||
设计原则:封面一律从最终成片(已叠加标题/B-roll)抽帧,帧本身已含标题,
|
||||
本服务**不再叠加标题**。对口型阶段的裸视频封面入口已删除(废弃)。
|
||||
|
||||
降级:MediaKit 不可用或抽帧失败时返回空字符串,由调用方决定回退策略。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -51,7 +52,6 @@ def _sign_video_url_for_mediakit(video_url: str) -> str:
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
url_host = urlparse(video_url).netloc.lower()
|
||||
if own_host and url_host == own_host:
|
||||
# 是自家 OSS URL,重签 7 天有效期供 MediaKit 拉取
|
||||
signed = storage.get_download_url(video_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
|
||||
if signed:
|
||||
logger.info("[数字人封面] video_url 已重签(自家 OSS 私有桶)")
|
||||
@@ -62,19 +62,10 @@ def _sign_video_url_for_mediakit(video_url: str) -> str:
|
||||
|
||||
|
||||
def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
|
||||
"""从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL.
|
||||
|
||||
Args:
|
||||
video_url: 可公网访问的视频 URL
|
||||
max_frames: 抽帧数量
|
||||
|
||||
Returns:
|
||||
最佳帧图片 URL;失败返回空字符串
|
||||
"""
|
||||
"""从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL."""
|
||||
if not video_url:
|
||||
return ""
|
||||
|
||||
# 确保 MediaKit 能访问 video_url(自家 OSS 私有桶需重签)
|
||||
video_url = _sign_video_url_for_mediakit(video_url)
|
||||
|
||||
try:
|
||||
@@ -87,11 +78,9 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
|
||||
return ""
|
||||
|
||||
logger.info(
|
||||
"[数字人封面] 开始抽帧: video_url=%s max_frames=%d poll_interval=%.1f max_poll=%d",
|
||||
"[数字人封面] 开始抽帧: video_url=%s max_frames=%d",
|
||||
video_url[:80],
|
||||
max_frames,
|
||||
COVER_POLL_INTERVAL,
|
||||
COVER_MAX_POLL_ATTEMPTS,
|
||||
)
|
||||
|
||||
snapshots = mk.extract_frames(
|
||||
@@ -109,7 +98,6 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
|
||||
if len(snapshots) == 1:
|
||||
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
|
||||
# 使用连接池下载各帧(复用 TCP 连接,减少延迟)
|
||||
import httpx
|
||||
|
||||
candidates = []
|
||||
@@ -137,7 +125,6 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
|
||||
best = scored[0] if scored else None
|
||||
best_url = best.get("url", "") if best else ""
|
||||
|
||||
# 清理临时文件
|
||||
for c in candidates:
|
||||
p = c.get("image_path")
|
||||
if p:
|
||||
@@ -158,87 +145,19 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def apply_title_to_cover(local_frame: str, *, title_config: dict | None) -> str:
|
||||
"""用 ffmpeg drawtext 在封面图上叠加标题,返回叠加后图片的本地路径.
|
||||
|
||||
ffmpeg 失败时回退返回原始 local_frame。竖屏封面按 720x1280 计算位置。
|
||||
"""
|
||||
if not title_config or not isinstance(title_config, dict):
|
||||
return local_frame
|
||||
text = (title_config.get("text") or title_config.get("content") or "").strip()
|
||||
if not text:
|
||||
return local_frame
|
||||
enabled = title_config.get("enabled", True)
|
||||
if not enabled:
|
||||
return local_frame
|
||||
|
||||
try:
|
||||
from packages.domain.video_filter_builder import build_title_drawtext_filter
|
||||
|
||||
drawtext_filter = build_title_drawtext_filter(
|
||||
title_config,
|
||||
output_width=720,
|
||||
output_height=1280,
|
||||
)
|
||||
if not drawtext_filter:
|
||||
return local_frame
|
||||
|
||||
base, ext = os.path.splitext(local_frame)
|
||||
titled_path = f"{base}_titled{ext or '.jpg'}"
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
local_frame,
|
||||
"-vf",
|
||||
drawtext_filter,
|
||||
"-y",
|
||||
titled_path,
|
||||
]
|
||||
logger.info("[数字人封面] 叠加标题: text=%s", text[:30])
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"[数字人封面] drawtext 失败,回退无标题: exit=%s stderr=%s",
|
||||
result.returncode,
|
||||
(result.stderr or "")[-300:],
|
||||
)
|
||||
return local_frame
|
||||
if not os.path.exists(titled_path) or os.path.getsize(titled_path) == 0:
|
||||
logger.warning("[数字人封面] drawtext 输出为空,回退无标题")
|
||||
return local_frame
|
||||
return titled_path
|
||||
except Exception as exc:
|
||||
logger.warning("[数字人封面] 标题叠加异常,回退无标题: %s", exc, exc_info=True)
|
||||
return local_frame
|
||||
|
||||
|
||||
def persist_cover_to_oss(
|
||||
frame_url: str,
|
||||
*,
|
||||
job_id: str = "",
|
||||
prefix: str = "ai-avatar/covers",
|
||||
title_config: dict | None = None,
|
||||
) -> str:
|
||||
"""下载帧图并转存到 OSS,返回公网封面 URL.
|
||||
"""下载最佳帧图并转存到 OSS,返回公网封面 URL(预签名).
|
||||
|
||||
Args:
|
||||
frame_url: MediaKit 返回的临时帧图 URL
|
||||
job_id: 关联任务 ID(用于 OSS key 命名)
|
||||
prefix: OSS key 前缀
|
||||
title_config: 可选标题配置;传入时用 drawtext 叠加标题(竖屏 720x1280)
|
||||
|
||||
Returns:
|
||||
OSS 公网 URL;失败回退原始 frame_url
|
||||
封面来自最终成片抽帧,帧本身已含标题,本函数不再做任何文字/图片叠加。
|
||||
"""
|
||||
if not frame_url:
|
||||
return ""
|
||||
tmp_path: Optional[str] = None
|
||||
titled_path: Optional[str] = None
|
||||
try:
|
||||
import httpx
|
||||
|
||||
@@ -259,21 +178,12 @@ def persist_cover_to_oss(
|
||||
token = job_id or uuid.uuid4().hex[:12]
|
||||
cover_key = f"{prefix}/{token}/cover_{uuid.uuid4().hex[:8]}.jpg"
|
||||
|
||||
upload_path = apply_title_to_cover(tmp_path, title_config=title_config)
|
||||
if upload_path != tmp_path:
|
||||
titled_path = upload_path
|
||||
|
||||
public_url = storage.upload_file(
|
||||
file_or_path=upload_path,
|
||||
file_or_path=tmp_path,
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
logger.info(
|
||||
"[数字人封面] 封面已转存 OSS: key=%s titled=%s",
|
||||
cover_key,
|
||||
bool(titled_path),
|
||||
)
|
||||
# 私有桶:返回预签名 URL(前端才能加载)
|
||||
logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key)
|
||||
if public_url:
|
||||
signed = storage.get_download_url(cover_key, expires_seconds=86400)
|
||||
return signed
|
||||
@@ -282,12 +192,11 @@ def persist_cover_to_oss(
|
||||
logger.warning("[数字人封面] 封面转存 OSS 失败,返回原始 URL", exc_info=True)
|
||||
return frame_url
|
||||
finally:
|
||||
for p in (tmp_path, titled_path):
|
||||
if p:
|
||||
try:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if tmp_path:
|
||||
try:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def generate_smart_cover(
|
||||
@@ -295,19 +204,12 @@ def generate_smart_cover(
|
||||
*,
|
||||
job_id: str = "",
|
||||
max_frames: int = 5,
|
||||
title_config: dict | None = None,
|
||||
) -> str:
|
||||
"""一站式:MediaKit 智能抽帧选最佳 → (可选)drawtext 叠加标题 → 转存 OSS.
|
||||
"""一站式:MediaKit 智能抽帧选最佳 → 转存 OSS。失败返回空字符串。
|
||||
|
||||
供独立封面接口与渲染管线复用。失败返回空字符串。
|
||||
|
||||
Args:
|
||||
video_url: 可公网访问的视频 URL
|
||||
job_id: 关联任务 ID
|
||||
max_frames: 抽帧数量
|
||||
title_config: 可选标题配置;传入时在封面上叠加 drawtext 标题(竖屏 720x1280)
|
||||
封面从最终成片抽帧,不再叠加任何标题(帧本身已含)。
|
||||
"""
|
||||
best_frame = select_best_cover_frame(video_url, max_frames=max_frames)
|
||||
if not best_frame:
|
||||
return ""
|
||||
return persist_cover_to_oss(best_frame, job_id=job_id, title_config=title_config)
|
||||
return persist_cover_to_oss(best_frame, job_id=job_id)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
@@ -27,6 +29,7 @@ from packages.adapters.sqlalchemy_impl.models import (
|
||||
from packages.domain.video_filter_builder import (
|
||||
build_broll_overlay_filter,
|
||||
build_title_drawtext_filter,
|
||||
build_title_overlay_filter,
|
||||
)
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
@@ -197,9 +200,8 @@ class AiAvatarRenderService:
|
||||
1. 下载对口型输出视频 (20%)
|
||||
2. 构建 FFmpeg 滤镜链 (40%)
|
||||
3. 执行 FFmpeg 渲染 (80%)
|
||||
4. 提取封面 (90%)
|
||||
5. 上传到 OSS (95%)
|
||||
6. 更新任务状态 (100%)
|
||||
4. 上传到 OSS (95%) — 封面不再自动生成,改由前端主动抽帧
|
||||
5. 更新任务状态 (100%)
|
||||
"""
|
||||
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
|
||||
if job is None:
|
||||
@@ -249,29 +251,12 @@ class AiAvatarRenderService:
|
||||
output_height=output_height,
|
||||
)
|
||||
|
||||
# 标题叠加(传入实际输出尺寸,保证位置计算正确)
|
||||
title_filter = build_title_drawtext_filter(
|
||||
job.title_config,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
)
|
||||
|
||||
filter_complex = ""
|
||||
final_label = None
|
||||
if broll_filter and title_filter:
|
||||
# B-roll → 标题叠在 B-roll 输出上
|
||||
filter_complex = broll_filter + f";[{broll_label}]{title_filter}[vout_titled]"
|
||||
final_label = "vout_titled"
|
||||
elif broll_filter:
|
||||
filter_complex = broll_filter
|
||||
final_label = broll_label
|
||||
elif title_filter:
|
||||
filter_complex = f"[0:v]{title_filter}[vout_titled]"
|
||||
final_label = "vout_titled"
|
||||
else:
|
||||
# 无滤镜:直接拷贝视频流
|
||||
filter_complex = ""
|
||||
final_label = None
|
||||
# 标题叠加路径:优先前端 Canvas 渲染的 PNG 图层(所见即所得),
|
||||
# 无 title_image_dataurl 时降级到 drawtext 重画文字。
|
||||
title_cfg = job.title_config if isinstance(job.title_config, dict) else {}
|
||||
title_dataurl = (title_cfg or {}).get("title_image_dataurl") if title_cfg else None
|
||||
use_title_png = isinstance(title_dataurl, str) and title_dataurl.startswith("data:image/")
|
||||
title_input_index = 1 + len(job.b_roll_segments or []) if use_title_png else None
|
||||
|
||||
job.progress = 40
|
||||
self.db.commit()
|
||||
@@ -280,9 +265,81 @@ class AiAvatarRenderService:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_video_path = os.path.join(tmpdir, "output.mp4")
|
||||
|
||||
# 在临时目录里解码保存标题 PNG(with 退出自动清理)
|
||||
title_png_path: Optional[str] = None
|
||||
extra_inputs: list[str] = []
|
||||
title_filter = None
|
||||
if use_title_png:
|
||||
try:
|
||||
title_png_path = os.path.join(tmpdir, f"title_{job.id}.png")
|
||||
self._save_title_dataurl_to_file(title_dataurl, dst_path=title_png_path)
|
||||
extra_inputs.append(title_png_path)
|
||||
logger.info(
|
||||
"[数字人渲染] 标题 PNG 已保存: %s (input index %d)", title_png_path, title_input_index
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[数字人渲染] 标题 PNG 解码/保存失败,降级 drawtext: %s", exc)
|
||||
title_png_path = None
|
||||
extra_inputs = []
|
||||
|
||||
# 构建标题滤镜
|
||||
final_label = None
|
||||
if title_png_path and title_input_index is not None:
|
||||
title_input_label = f"[{title_input_index}:v]"
|
||||
base_label = f"[{broll_label}]" if broll_label else "[0:v]"
|
||||
title_filter = build_title_overlay_filter(
|
||||
title_cfg,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
title_png_path=title_png_path,
|
||||
title_input_label=title_input_label,
|
||||
base_label=base_label,
|
||||
output_label="vout_titled",
|
||||
)
|
||||
if not title_filter:
|
||||
# build 返回 None → 文件不存在(极端并发情况),降级 drawtext
|
||||
title_png_path = None
|
||||
extra_inputs = []
|
||||
|
||||
if title_png_path:
|
||||
# overlay 路径
|
||||
if broll_filter and title_filter:
|
||||
filter_complex = broll_filter + f";{title_filter}"
|
||||
elif broll_filter:
|
||||
filter_complex = broll_filter
|
||||
final_label = broll_label
|
||||
elif title_filter:
|
||||
filter_complex = title_filter
|
||||
else:
|
||||
filter_complex = ""
|
||||
if title_filter:
|
||||
final_label = "vout_titled"
|
||||
elif not final_label:
|
||||
final_label = None
|
||||
else:
|
||||
# 降级:drawtext 重画文字
|
||||
title_filter = build_title_drawtext_filter(
|
||||
title_cfg,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
)
|
||||
if broll_filter and title_filter:
|
||||
filter_complex = broll_filter + f";[{broll_label}]{title_filter}[vout_titled]"
|
||||
final_label = "vout_titled"
|
||||
elif broll_filter:
|
||||
filter_complex = broll_filter
|
||||
final_label = broll_label
|
||||
elif title_filter:
|
||||
filter_complex = f"[0:v]{title_filter}[vout_titled]"
|
||||
final_label = "vout_titled"
|
||||
else:
|
||||
filter_complex = ""
|
||||
final_label = None
|
||||
|
||||
cmd_list = self._build_ffmpeg_command(
|
||||
input_video=input_video_path,
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
extra_inputs=extra_inputs,
|
||||
filter_complex=filter_complex,
|
||||
final_label=final_label,
|
||||
output_path=output_video_path,
|
||||
@@ -311,64 +368,23 @@ class AiAvatarRenderService:
|
||||
job.progress = 80
|
||||
self.db.commit()
|
||||
|
||||
# 4. 提取封面 (90%)
|
||||
cover_path = ""
|
||||
if job.cover_config:
|
||||
cover_path = os.path.join(tmpdir, "cover.jpg")
|
||||
cover_cmd = self._build_cover_extract_cmd(
|
||||
cover_config=job.cover_config,
|
||||
input_video=output_video_path,
|
||||
output_path=cover_path,
|
||||
)
|
||||
try:
|
||||
cover_result = subprocess.run(
|
||||
cover_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if cover_result.returncode != 0:
|
||||
logger.warning(
|
||||
"封面提取失败(非致命),跳过: exit=%s stderr=%s",
|
||||
cover_result.returncode,
|
||||
(cover_result.stderr or "")[-300:],
|
||||
)
|
||||
cover_path = ""
|
||||
except Exception as cover_err:
|
||||
logger.warning("封面提取异常(非致命),跳过: %s", cover_err)
|
||||
cover_path = ""
|
||||
|
||||
job.progress = 90
|
||||
self.db.commit()
|
||||
|
||||
# 5. 上传到 OSS (95%)
|
||||
# 4/5. 上传成片到 OSS (95%) —— 已砍掉自动抽封面逻辑(步骤⑤);
|
||||
# 封面由前端在渲染完成后通过 /smart-cover 接口主动从成片抽帧,不阻塞渲染链路。
|
||||
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
|
||||
job.output_video_url = output_video_url
|
||||
|
||||
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧(支持 drawtext 标题叠加);
|
||||
# MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
|
||||
smart_cover_url = ""
|
||||
if output_video_url:
|
||||
try:
|
||||
from app.services.ai_avatar_cover_service import (
|
||||
generate_smart_cover,
|
||||
)
|
||||
|
||||
smart_cover_url = generate_smart_cover(
|
||||
output_video_url,
|
||||
job_id=job_id,
|
||||
max_frames=5,
|
||||
# 注意:不传 title_config —— 最终输出视频已经通过 drawtext 叠加了标题,
|
||||
# 再传会导致封面标题双重叠加
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True)
|
||||
|
||||
if smart_cover_url:
|
||||
job.output_cover_url = smart_cover_url
|
||||
elif cover_path:
|
||||
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
|
||||
job.output_cover_url = output_cover_url
|
||||
# 封面透传:如果用户已在 cover_config 中选定封面 URL(mode=upload 的自定义上传 或
|
||||
# mode=auto_frame 已有的智能封面结果),直接透传到 output_cover_url,不再重新截帧。
|
||||
if isinstance(job.cover_config, dict):
|
||||
_pre_cover_url = (
|
||||
job.cover_config.get("url")
|
||||
or job.cover_config.get("imageUrl")
|
||||
or job.cover_config.get("cover_url")
|
||||
or ""
|
||||
)
|
||||
if _pre_cover_url:
|
||||
job.output_cover_url = _pre_cover_url
|
||||
logger.info("[数字人渲染] 使用用户已选定封面 URL: job_id=%s", job_id)
|
||||
|
||||
# 获取输出视频时长
|
||||
job.output_duration = lipsync_job.output_duration
|
||||
@@ -450,6 +466,44 @@ class AiAvatarRenderService:
|
||||
os.unlink(tmp.name)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _save_title_dataurl_to_file(dataurl: str, *, dst_path: str | None = None, job_id: str = "") -> str:
|
||||
"""解码前端传来的 data:image/png;base64,... 并保存为本地 PNG 文件。
|
||||
|
||||
Args:
|
||||
dataurl: 完整 dataURL 字符串
|
||||
dst_path: 指定输出路径;为 None 时创建临时文件并返回路径
|
||||
job_id: 仅在 dst_path 为空时用于临时文件命名
|
||||
|
||||
Returns:
|
||||
保存后的本地文件路径
|
||||
"""
|
||||
if not isinstance(dataurl, str) or not dataurl.startswith("data:image/"):
|
||||
raise ValueError("title_image_dataurl 不是合法的 data:image URL")
|
||||
# 拆分 data:image/png;base64,<payload>
|
||||
try:
|
||||
header, b64 = dataurl.split(",", 1)
|
||||
except ValueError as exc:
|
||||
raise ValueError("title_image_dataurl 缺少 base64 payload") from exc
|
||||
if "base64" not in header:
|
||||
raise ValueError("title_image_dataurl 不是 base64 编码")
|
||||
try:
|
||||
png_bytes = base64.b64decode(b64, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ValueError(f"title_image_dataurl base64 解码失败: {exc}") from exc
|
||||
if not png_bytes:
|
||||
raise ValueError("title_image_dataurl 解码后为空")
|
||||
|
||||
if dst_path:
|
||||
out_path = dst_path
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(png_bytes)
|
||||
return out_path
|
||||
suffix = f"_title_{job_id}.png" if job_id else "_title.png"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(png_bytes)
|
||||
return tmp.name
|
||||
|
||||
@staticmethod
|
||||
def _probe_video_resolution(video_path: str) -> tuple[int, int]:
|
||||
"""用 ffprobe 探测视频分辨率,返回 (width, height);失败返回 (0, 0)。"""
|
||||
@@ -486,6 +540,7 @@ class AiAvatarRenderService:
|
||||
*,
|
||||
input_video: str,
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
extra_inputs: list[str] | None = None,
|
||||
filter_complex: str,
|
||||
final_label: Optional[str],
|
||||
output_path: str,
|
||||
@@ -502,6 +557,9 @@ class AiAvatarRenderService:
|
||||
asset_url = seg.get("asset_url", "")
|
||||
if asset_url:
|
||||
cmd.extend(["-i", asset_url])
|
||||
# 额外输入(例如前端 Canvas 渲染的标题 PNG)
|
||||
for extra in extra_inputs or []:
|
||||
cmd.extend(["-i", extra])
|
||||
|
||||
if filter_complex and final_label:
|
||||
cmd.extend(
|
||||
@@ -535,41 +593,6 @@ class AiAvatarRenderService:
|
||||
)
|
||||
return cmd
|
||||
|
||||
def _build_cover_extract_cmd(
|
||||
self,
|
||||
*,
|
||||
cover_config: dict[str, Any],
|
||||
input_video: str,
|
||||
output_path: str,
|
||||
) -> list[str]:
|
||||
"""构建封面截帧 FFmpeg 命令(list 形式,shell=False)."""
|
||||
if not cover_config or not isinstance(cover_config, dict):
|
||||
timestamp = 0.0
|
||||
width = 0
|
||||
height = 0
|
||||
else:
|
||||
timestamp = cover_config.get("timestamp", 0.0)
|
||||
width = cover_config.get("width", 0)
|
||||
height = cover_config.get("height", 0)
|
||||
|
||||
cmd: list[str] = [
|
||||
"ffmpeg",
|
||||
"-ss",
|
||||
str(timestamp),
|
||||
"-i",
|
||||
input_video,
|
||||
"-frames:v",
|
||||
"1",
|
||||
]
|
||||
if width > 0 and height > 0:
|
||||
vf = (
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
|
||||
)
|
||||
cmd.extend(["-vf", vf])
|
||||
cmd.extend(["-y", output_path])
|
||||
return cmd
|
||||
|
||||
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
|
||||
"""上传文件到 OSS,返回 URL.
|
||||
|
||||
|
||||
@@ -198,6 +198,12 @@ class LipsyncService:
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
# ⚠️ 必须先 commit 再发 Celery 任务,避免事务竞态:
|
||||
# worker 是独立进程+独立DB连接,任务被消费(<4ms)时若本事务还未提交,
|
||||
# worker 查询 job 会返回 None → 静默 return 不重试,job 永远卡在 tts_processing。
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
|
||||
if is_tts_mode:
|
||||
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交
|
||||
try:
|
||||
@@ -223,6 +229,7 @@ class LipsyncService:
|
||||
job.error_message = f"Celery 任务投递失败: {exc}"
|
||||
job.error_code = "AsyncDispatchFailed"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit() # 投递失败也要落库失败状态
|
||||
else:
|
||||
# 2b. 直接音频模式:同步签名并提交 MediaKit
|
||||
video_url = self._sign_media_url(video_url)
|
||||
@@ -240,15 +247,15 @@ class LipsyncService:
|
||||
job.mediakit_task_id = result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
self.db.commit() # submitted 状态落库
|
||||
except MediaKitError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.error_code = exc.code
|
||||
logger.error("提交对口型任务失败: %s", exc)
|
||||
self.db.commit()
|
||||
raise
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 查询任务 ──────────────────────────────────────────────────────────
|
||||
@@ -313,11 +320,26 @@ class LipsyncService:
|
||||
if mk_status == STATUS_COMPLETED:
|
||||
result = status_data.get("result", {})
|
||||
job.status = STATUS_COMPLETED
|
||||
output_url = result.get("video_url", "")
|
||||
# MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL)
|
||||
job.output_video_url = self._persist_output_video(output_url, job_id, user_id)
|
||||
temp_url = result.get("video_url", "")
|
||||
# 先以临时 URL 立即返回前端(前端可立即播放),再异步 Celery 任务转存自家 OSS(步骤⑦)
|
||||
job.output_video_url = temp_url
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
# 异步转存到自家 OSS(注意:必须在 commit 之后 dispatch,避免 commit 失败任务已发出)
|
||||
try:
|
||||
from app.tasks.lipsync_tts import persist_output_video_task
|
||||
|
||||
persist_output_video_task.apply_async(args=(job_id, user_id, temp_url))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
elif mk_status == STATUS_FAILED:
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
|
||||
@@ -54,11 +54,174 @@ def _sign_media_url(url: str) -> str:
|
||||
return url
|
||||
|
||||
|
||||
def _split_script_into_sentences(script_text: str) -> list[str]:
|
||||
"""按句号/问号/感叹号/分号/逗号/换行分句(与前端 SENTENCE_SPLIT_RE 一致).
|
||||
|
||||
中文短视频文案习惯用「,」断小句(如"卖花的叫花无缺,卖姜的叫姜子牙"),
|
||||
必须把逗号也纳入分隔符,否则多句文案会被识别成一整句,导致 B-roll 时间戳错位。
|
||||
"""
|
||||
import re
|
||||
|
||||
text = (script_text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
parts = re.split(r"[。!?!??!;;,,\n\r]+", text)
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def _compute_sentence_timings(audio_data: bytes, script_text: str, total_duration: float) -> list[dict]:
|
||||
"""基于 TTS 音频的静音检测,精确计算每句文案的起止时间.
|
||||
|
||||
使用 ffmpeg silencedetect 检测静音段,将静音点与句子边界对齐。
|
||||
比字数比例估算准确得多。
|
||||
|
||||
Args:
|
||||
audio_data: TTS 音频二进制数据(MP3)
|
||||
script_text: 文案全文
|
||||
total_duration: 音频总时长(秒)
|
||||
|
||||
Returns:
|
||||
list[{"index": int, "text": str, "start_time": float, "end_time": float}]
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
sentences = _split_script_into_sentences(script_text)
|
||||
if not sentences:
|
||||
return []
|
||||
|
||||
# 写入临时音频文件
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
|
||||
tmp.write(audio_data)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# 用 ffmpeg silencedetect 检测静音段
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
tmp_path,
|
||||
"-af",
|
||||
"silencedetect=noise=-25dB:d=0.3",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
stderr = result.stderr or ""
|
||||
|
||||
# 解析静音结束时间点(silence_end: X.XXX)
|
||||
silence_ends = []
|
||||
for match in re.finditer(r"silence_end:\s*([\d.]+)", stderr):
|
||||
t = float(match.group(1))
|
||||
if 0 < t < total_duration:
|
||||
silence_ends.append(t)
|
||||
|
||||
# 如果没有检测到足够的静音点,降级为字数比例估算
|
||||
if len(silence_ends) < len(sentences) - 1:
|
||||
logger.warning(
|
||||
"[sentence_timings] 静音点不足(%d < %d),降级为字数比例估算",
|
||||
len(silence_ends),
|
||||
len(sentences) - 1,
|
||||
)
|
||||
return _estimate_sentence_timings_by_chars(sentences, total_duration)
|
||||
|
||||
# 贪心匹配:N-1 个句子边界对应 N-1 个静音点
|
||||
# 按时间均匀分布期望值,选择最近的静音点
|
||||
n_boundaries = len(sentences) - 1
|
||||
boundaries = []
|
||||
used_indices = set()
|
||||
|
||||
for i in range(n_boundaries):
|
||||
# 期望的边界位置(按句子数量均匀分布)
|
||||
expected_pos = (i + 1) / len(sentences) * total_duration
|
||||
# 找最近的未使用静音点
|
||||
best_idx = None
|
||||
best_dist = float("inf")
|
||||
for j, t in enumerate(silence_ends):
|
||||
if j in used_indices:
|
||||
continue
|
||||
dist = abs(t - expected_pos)
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_idx = j
|
||||
if best_idx is not None:
|
||||
used_indices.add(best_idx)
|
||||
boundaries.append(silence_ends[best_idx])
|
||||
|
||||
boundaries.sort()
|
||||
|
||||
# 构建 sentence_timings
|
||||
timings = []
|
||||
prev_end = 0.0
|
||||
for i, sent in enumerate(sentences):
|
||||
start = prev_end
|
||||
end = boundaries[i] if i < len(boundaries) else total_duration
|
||||
timings.append(
|
||||
{
|
||||
"index": i,
|
||||
"text": sent,
|
||||
"start_time": round(start, 2),
|
||||
"end_time": round(end, 2),
|
||||
}
|
||||
)
|
||||
prev_end = end
|
||||
|
||||
return timings
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("[sentence_timings] 静音检测异常,降级为字数比例估算: %s", exc)
|
||||
return _estimate_sentence_timings_by_chars(sentences, total_duration)
|
||||
finally:
|
||||
import os
|
||||
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _estimate_sentence_timings_by_chars(sentences: list[str], total_duration: float) -> list[dict]:
|
||||
"""降级方案:按字数比例估算句子时间(与原前端逻辑一致)."""
|
||||
if not sentences or total_duration <= 0:
|
||||
return []
|
||||
total_chars = sum(len(s.replace(r"\s", "")) for s in sentences)
|
||||
if total_chars == 0:
|
||||
return []
|
||||
|
||||
timings = []
|
||||
acc = 0
|
||||
for i, sent in enumerate(sentences):
|
||||
chars = len(sent.replace(r"\s", ""))
|
||||
start = (acc / total_chars) * total_duration
|
||||
end = ((acc + chars) / total_chars) * total_duration
|
||||
timings.append(
|
||||
{
|
||||
"index": i,
|
||||
"text": sent,
|
||||
"start_time": round(start, 2),
|
||||
"end_time": round(end, 2),
|
||||
}
|
||||
)
|
||||
acc += chars
|
||||
return timings
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="lipsync_tts.synthesize_and_submit",
|
||||
max_retries=2,
|
||||
max_retries=5, # 事务竞态重试3次(job not found)+ TTS偶发错误2次
|
||||
default_retry_delay=30,
|
||||
autoretry_for=(OSError, ConnectionError), # 网络/连接错误自动重试
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=30,
|
||||
soft_time_limit=180,
|
||||
time_limit=200,
|
||||
)
|
||||
def tts_synthesize_and_submit(
|
||||
self,
|
||||
@@ -103,7 +266,28 @@ def tts_synthesize_and_submit(
|
||||
)
|
||||
|
||||
if job is None:
|
||||
logger.error("[lipsync_tts] Job not found: job_id=%s", job_id)
|
||||
# 事务竞态防御:API 在 commit 前投递了任务,worker 消费时事务尚未提交。
|
||||
# Celery 内置 autoretry_for 不支持"业务条件重试",这里手动 retry 3 次,
|
||||
# 间隔递增(1s/3s/7s),让 API 事务有时间提交。
|
||||
# max_retries 由 self.request(retries) 维护;默认 self.max_retries=3 由装饰器 soft_time_limit 下方指定。
|
||||
retries = getattr(self.request, "retries", 0)
|
||||
max_retries = 3
|
||||
if retries < max_retries:
|
||||
backoff = (2**retries) + (retries * 1) # 1s, 3s, 7s
|
||||
logger.warning(
|
||||
"[lipsync_tts] Job not found yet (retry %d/%d, backoff %ds): job_id=%s",
|
||||
retries + 1,
|
||||
max_retries,
|
||||
backoff,
|
||||
job_id,
|
||||
)
|
||||
self.db.close()
|
||||
raise self.retry(countdown=backoff, max_retries=max_retries)
|
||||
logger.error(
|
||||
"[lipsync_tts] Job not found after %d retries, giving up: job_id=%s",
|
||||
max_retries,
|
||||
job_id,
|
||||
)
|
||||
return
|
||||
|
||||
# 已取消的任务不再处理
|
||||
@@ -112,6 +296,13 @@ def tts_synthesize_and_submit(
|
||||
return
|
||||
|
||||
# 1. TTS 合成
|
||||
logger.info(
|
||||
"[lipsync_tts] 开始 TTS 合成: job_id=%s voice_id=%s text_len=%d speed=%.2f",
|
||||
job_id,
|
||||
voice_id,
|
||||
len(script_text),
|
||||
speed,
|
||||
)
|
||||
try:
|
||||
cosyvoice = CosyVoiceService()
|
||||
result = cosyvoice.submit_synthesize_task(
|
||||
@@ -147,38 +338,113 @@ def tts_synthesize_and_submit(
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# 2. 下载并转存到自家 OSS
|
||||
# 2. 下载 TTS 音频到内存(用于 2.5 静音检测;不转存自家 OSS,直接使用 CosyVoice 临时 URL)
|
||||
audio_data: bytes | None = None
|
||||
_st_tmp_path: str | None = None
|
||||
try:
|
||||
audio_data = safe_download_bytes(
|
||||
temp_url,
|
||||
purpose="lipsync_tts_audio",
|
||||
allowed_mime_types=(
|
||||
allowed_mime_types={
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/x-wav", # CosyVoice 部分接口返回 audio/x-wav,与 audio/wav 等价(RIFF/WAVE)
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
),
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
|
||||
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
|
||||
logger.info("[lipsync_tts] TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||||
job.audio_url = permanent_url
|
||||
logger.info(
|
||||
"[lipsync_tts] TTS 音频已下载到内存: job_id=%s size=%d",
|
||||
job_id,
|
||||
len(audio_data) if audio_data else 0,
|
||||
)
|
||||
except Exception as exc:
|
||||
# 下载失败:audio_data 保持 None,2.5 静音检测会跳过;后续仍用 temp_url 提交 MediaKit
|
||||
logger.warning(
|
||||
"[lipsync_tts] TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s",
|
||||
"[lipsync_tts] TTS 音频下载失败,跳过静音检测,直接使用临时 URL 提交: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
job.audio_url = temp_url
|
||||
# TTS 音频使用 CosyVoice 临时 URL,跳过自家 OSS 转存(加速,步骤⑥)
|
||||
job.audio_url = temp_url
|
||||
logger.info("[lipsync_tts] TTS 音频使用 CosyVoice 临时 URL(跳过 OSS 转存): job_id=%s", job_id)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 2.5 计算精确句子时间戳(基于 TTS 音频静音检测)
|
||||
# 直接复用步骤 2 已下载到内存的 audio_data,避免重新下载
|
||||
import os as _os
|
||||
|
||||
try:
|
||||
import subprocess as _sp
|
||||
import tempfile as _tmpf
|
||||
|
||||
if not audio_data:
|
||||
logger.warning("[lipsync_tts] 无音频数据,跳过句子时间戳计算: job_id=%s", job_id)
|
||||
else:
|
||||
# 写入临时文件供 ffprobe/ffmpeg 使用
|
||||
with _tmpf.NamedTemporaryFile(suffix=".mp3", delete=False) as _atmp:
|
||||
_atmp.write(audio_data)
|
||||
_st_tmp_path = _atmp.name
|
||||
|
||||
# ffprobe 获取音频时长
|
||||
_probe_result = _sp.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
_st_tmp_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
_audio_duration = float(_probe_result.stdout.strip()) if _probe_result.stdout.strip() else 0.0
|
||||
logger.info(
|
||||
"[lipsync_tts] 音频时长探测: job_id=%s duration=%.2f probe_stdout=%s probe_stderr=%s",
|
||||
job_id,
|
||||
_audio_duration,
|
||||
_probe_result.stdout.strip()[:50],
|
||||
_probe_result.stderr.strip()[:100] if _probe_result.stderr else "",
|
||||
)
|
||||
|
||||
if _audio_duration > 0:
|
||||
_timings = _compute_sentence_timings(audio_data, script_text, _audio_duration)
|
||||
if _timings:
|
||||
job.sentence_timings = _timings
|
||||
logger.info(
|
||||
"[lipsync_tts] 句子时间戳已计算: job_id=%s sentences=%d duration=%.1f",
|
||||
job_id,
|
||||
len(_timings),
|
||||
_audio_duration,
|
||||
)
|
||||
else:
|
||||
logger.warning("[lipsync_tts] 句子时间戳计算返回空结果: job_id=%s", job_id)
|
||||
else:
|
||||
logger.warning(
|
||||
"[lipsync_tts] ffprobe 未获取到有效时长,跳过句子时间戳: job_id=%s stdout=%s stderr=%s",
|
||||
job_id,
|
||||
_probe_result.stdout.strip()[:100],
|
||||
_probe_result.stderr.strip()[:200] if _probe_result.stderr else "",
|
||||
)
|
||||
db.commit()
|
||||
except Exception as _st_err:
|
||||
logger.warning(
|
||||
"[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True
|
||||
)
|
||||
finally:
|
||||
if _st_tmp_path:
|
||||
try:
|
||||
_os.unlink(_st_tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
|
||||
audio_url = _sign_media_url(job.audio_url)
|
||||
video_url = _sign_media_url(job.video_url)
|
||||
@@ -221,3 +487,64 @@ def tts_synthesize_and_submit(
|
||||
logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="lipsync_tts.persist_output_video",
|
||||
max_retries=2,
|
||||
default_retry_delay=30,
|
||||
)
|
||||
def persist_output_video_task(job_id: str, user_id: str, temp_url: str):
|
||||
"""异步转存对口型输出视频到自家 OSS(步骤⑦ — 将同步阻塞挪到后台,加速前端响应).
|
||||
|
||||
- MediaKit 返回 completed 后先以 temp_url 回前端(前端可立即播放临时 URL)
|
||||
- Celery 后台下载 temp_url 并转存 OSS,成功后更新 job.output_video_url 为永久 URL
|
||||
- 失败则保留 temp_url,不阻断主流程
|
||||
"""
|
||||
|
||||
try:
|
||||
from worker_app.db import SessionLocal # type: ignore
|
||||
except Exception: # noqa: BLE001
|
||||
from app.db import SessionLocal # type: ignore
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first()
|
||||
if job is None:
|
||||
logger.error("[lipsync_tts.persist] Job not found: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
if not temp_url:
|
||||
logger.warning("[lipsync_tts.persist] temp_url 为空,跳过转存: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
with httpx.Client(timeout=180.0, follow_redirects=True) as client:
|
||||
resp = client.get(temp_url)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
storage_key = f"lipsync-outputs/{user_id}/{job_id}.mp4"
|
||||
permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4")
|
||||
# 对自家 OSS URL 重签 7 天有效期预签名,供前端播放
|
||||
final_url = _sign_media_url(permanent_url) if permanent_url else temp_url
|
||||
job.output_video_url = final_url
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("[lipsync_tts.persist] 输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[lipsync_tts.persist] 输出视频转存失败,保留临时 URL: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[lipsync_tts.persist] 未预期异常: job_id=%s", job_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -23,14 +23,16 @@ import {
|
||||
getLipsyncJob,
|
||||
submitRender,
|
||||
getRenderJob,
|
||||
generateSmartCover,
|
||||
generateRenderSmartCover,
|
||||
} from "./api/aiAvatar"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { RenderJob } from "./types"
|
||||
import {
|
||||
normalizeEmotion,
|
||||
buildTitleConfigPayload,
|
||||
buildCoverConfigPayload,
|
||||
} from "./utils/contract"
|
||||
import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas"
|
||||
|
||||
/** 面板折叠状态 */
|
||||
type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover"
|
||||
@@ -54,8 +56,6 @@ const AiAvatarPage: React.FC = () => {
|
||||
"generating",
|
||||
)
|
||||
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
|
||||
/* ── 智能封面加载态 ── */
|
||||
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
|
||||
/* ── 渲染进度弹窗 ── */
|
||||
const [showRenderModal, setShowRenderModal] = useState(false)
|
||||
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
|
||||
@@ -63,6 +63,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
)
|
||||
const [renderProgress, setRenderProgress] = useState(0)
|
||||
const [renderErrorMessage, setRenderErrorMessage] = useState("")
|
||||
/* ── 当前渲染任务对象(轮询更新;用于封面区判断渲染是否完成) ── */
|
||||
const [currentRenderJob, setCurrentRenderJob] = useState<RenderJob | null>(null)
|
||||
|
||||
/* ── 对口型轮询 ── */
|
||||
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
@@ -209,6 +211,23 @@ const AiAvatarPage: React.FC = () => {
|
||||
try {
|
||||
// 确保有 project_id(AI数字人入口独立,不在项目内,自动取默认项目;#1860 P0 bugfix)
|
||||
const defaultProject = await getOrCreateDefaultProject()
|
||||
|
||||
// 用 Canvas 预渲染标题为 PNG dataURL(所见即所得,后端用 overlay 直接叠加)
|
||||
let titleImageDataUrl: string | null = null
|
||||
if (state.titleConfig.title?.trim()) {
|
||||
try {
|
||||
const res = await getVideoResolution(state.lipsyncJob.output_video_url || "")
|
||||
titleImageDataUrl = renderTitleToPngDataUrl({
|
||||
titleConfig: state.titleConfig,
|
||||
videoWidth: res.width,
|
||||
videoHeight: res.height,
|
||||
})
|
||||
} catch (canvasErr) {
|
||||
console.warn("[渲染] 标题 Canvas 渲染失败,降级 drawtext:", canvasErr)
|
||||
titleImageDataUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
const job = await submitRender({
|
||||
lipsync_job_id: state.lipsyncJob.id,
|
||||
script_id: state.script?.id,
|
||||
@@ -222,8 +241,13 @@ const AiAvatarPage: React.FC = () => {
|
||||
pip_position: seg.pip_position,
|
||||
pip_scale: seg.pip_scale,
|
||||
})) as never,
|
||||
title_config: buildTitleConfigPayload(state.titleConfig),
|
||||
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
|
||||
title_config: buildTitleConfigPayload(state.titleConfig, titleImageDataUrl),
|
||||
// 封面不阻塞渲染:用户未选定封面时传空 dict,后端不生成封面;渲染完成后再单独抽帧
|
||||
cover_config:
|
||||
state.coverConfig.smart_cover_url ||
|
||||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
|
||||
? buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url)
|
||||
: {},
|
||||
})
|
||||
|
||||
// 打开渲染进度弹窗,启动轮询
|
||||
@@ -231,16 +255,28 @@ const AiAvatarPage: React.FC = () => {
|
||||
setRenderStatus("generating")
|
||||
setRenderProgress(job.progress ?? 0)
|
||||
setRenderErrorMessage("")
|
||||
setCurrentRenderJob(job as RenderJob)
|
||||
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await getRenderJob(job.id)
|
||||
setRenderProgress(updated.progress ?? 0)
|
||||
setCurrentRenderJob(updated)
|
||||
if (updated.status === "completed") {
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = null
|
||||
setRenderStatus("completed")
|
||||
// 渲染完成后:如果后端已返回封面(用户预上传/预设)则同步到前端;
|
||||
// 否则不自动设置封面,由用户在封面区点击"智能获取封面"主动抽帧(步骤③④)
|
||||
if (updated.output_cover_url) {
|
||||
state.setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
mode: "auto_frame",
|
||||
smart_cover_url: updated.output_cover_url,
|
||||
thumbnail_url: updated.output_cover_url,
|
||||
}))
|
||||
}
|
||||
message.success("视频已生成并保存到成片库")
|
||||
} else if (updated.status === "failed") {
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
@@ -273,38 +309,48 @@ const AiAvatarPage: React.FC = () => {
|
||||
setRenderErrorMessage("")
|
||||
}, [])
|
||||
|
||||
/* ── 智能封面:调后端 MediaKit 选帧接口(#1822) ── */
|
||||
const handleSmartCover = useCallback(async () => {
|
||||
// 基于对口型成片抽帧,必须先完成对口型
|
||||
const videoUrl = state.lipsyncJob?.output_video_url
|
||||
if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
|
||||
message.warning("请先生成对口型视频,完成后再智能获取封面")
|
||||
return
|
||||
}
|
||||
setSmartCoverLoading(true)
|
||||
try {
|
||||
const res = await generateSmartCover(videoUrl, buildTitleConfigPayload(state.titleConfig), 5)
|
||||
if (res.cover_url) {
|
||||
state.setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
mode: "auto_frame",
|
||||
smart_cover_url: res.cover_url,
|
||||
thumbnail_url: res.cover_url,
|
||||
}))
|
||||
message.success("智能封面已生成")
|
||||
} else {
|
||||
message.error(res.message || "智能封面生成失败,请稍后重试")
|
||||
/* ── 智能封面:从最终渲染成片抽帧(POST /renders/{id}/smart-cover,步骤③④) ── */
|
||||
const handleGenerateRenderSmartCover = useCallback(
|
||||
async (renderId: string): Promise<{ cover_url: string; message?: string }> => {
|
||||
try {
|
||||
const res = await generateRenderSmartCover(renderId)
|
||||
if (res.cover_url) {
|
||||
state.setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
mode: "auto_frame",
|
||||
smart_cover_url: res.cover_url,
|
||||
thumbnail_url: res.cover_url,
|
||||
}))
|
||||
message.success("智能封面已生成")
|
||||
return { cover_url: res.cover_url }
|
||||
}
|
||||
const errMsg = res.message || "智能封面生成失败,请稍后重试"
|
||||
message.error(errMsg)
|
||||
return { cover_url: "", message: errMsg }
|
||||
} catch (err) {
|
||||
console.error("智能封面生成失败:", err)
|
||||
const errMsg = err instanceof Error ? err.message : "智能封面生成失败,请重试"
|
||||
message.error(errMsg)
|
||||
return { cover_url: "", message: errMsg }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("智能封面生成失败:", err)
|
||||
message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试")
|
||||
} finally {
|
||||
setSmartCoverLoading(false)
|
||||
}
|
||||
},
|
||||
// state.setCoverConfig 是 zustand action 引用稳定,eslint 不需要检查
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.lipsyncJob])
|
||||
[],
|
||||
)
|
||||
|
||||
/* ── 配置汇总 ── */
|
||||
const coverStatus: "not_ready" | "pending" | "selected" = (() => {
|
||||
if (
|
||||
state.coverConfig.smart_cover_url ||
|
||||
state.coverConfig.thumbnail_url ||
|
||||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
|
||||
) {
|
||||
return "selected"
|
||||
}
|
||||
if (currentRenderJob?.status === "completed") return "pending"
|
||||
return "not_ready"
|
||||
})()
|
||||
const summary = {
|
||||
videoName: state.selectedVideo?.name || null,
|
||||
voiceName: state.selectedVoice?.name || null,
|
||||
@@ -312,7 +358,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
lipsyncStatus: state.lipsyncJob?.status || null,
|
||||
brollCount: state.bRollSegments.length,
|
||||
hasTitle: state.titleConfig.title.length > 0,
|
||||
hasCover: state.coverConfig.enabled,
|
||||
coverStatus,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -448,9 +494,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
titleConfig={state.titleConfig}
|
||||
onSmartCover={handleSmartCover}
|
||||
smartCoverLoading={smartCoverLoading}
|
||||
canSmartCover={state.lipsyncJob?.status === "completed"}
|
||||
renderJob={currentRenderJob}
|
||||
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
@@ -488,8 +533,9 @@ const AiAvatarPage: React.FC = () => {
|
||||
open={state.showBRollModal}
|
||||
onClose={() => state.setShowBRollModal(false)}
|
||||
existingSegments={state.bRollSegments}
|
||||
scriptText={state.scriptText}
|
||||
scriptText={state.lipsyncJob?.script_text || state.scriptText}
|
||||
outputDuration={state.lipsyncJob?.output_duration ?? 0}
|
||||
sentenceTimings={state.lipsyncJob?.sentence_timings}
|
||||
onConfirm={state.addBRollSegment}
|
||||
onRemove={state.removeBRollSegment}
|
||||
/>
|
||||
|
||||
@@ -58,21 +58,6 @@ export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧 + 可选 drawtext 标题叠加) ── */
|
||||
export const generateSmartCover = async (
|
||||
video_url: string,
|
||||
title_config?: Record<string, unknown> | null,
|
||||
max_frames = 5,
|
||||
): Promise<{ cover_url: string; status: string; message: string }> => {
|
||||
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
|
||||
"/ai-avatar/render/smart-cover",
|
||||
{ video_url, max_frames, title_config: title_config ?? null },
|
||||
// smart-cover 链路:下载视频+抽帧+drawtext 加标题+上传 OSS,需要较长时间,120s 超时
|
||||
{ timeout: 120000 },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 渲染 ── */
|
||||
export const submitRender = async (data: {
|
||||
lipsync_job_id: string
|
||||
@@ -82,6 +67,7 @@ export const submitRender = async (data: {
|
||||
cover_config?: Record<string, unknown>
|
||||
project_id?: string
|
||||
}): Promise<RenderJob> => {
|
||||
// title_config 内可含 title_image_dataurl(前端 Canvas 渲染的 PNG dataURL)
|
||||
const response = await apiClient.post<RenderJob>("/ai-avatar/render", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -94,3 +80,16 @@ export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
|
||||
export const cancelRenderJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
|
||||
}
|
||||
|
||||
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */
|
||||
export const generateRenderSmartCover = async (
|
||||
jobId: string,
|
||||
): Promise<{ cover_url: string; status: string; message: string }> => {
|
||||
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
|
||||
`/ai-avatar/render/${jobId}/smart-cover`,
|
||||
{},
|
||||
// 抽帧+评分+转存 OSS 链路较长,120s 超时
|
||||
{ timeout: 120000 },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* - 左侧:先选素材库(video 库)→ 再选该库视频素材(已被其他 segment 使用的素材
|
||||
* 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择)
|
||||
* - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小
|
||||
* (开始/结束时间已删除,按句子字数占比 × 口播总时长自动估算)
|
||||
* (开始/结束时间来自后端精确句子时间戳,基于 TTS 音频静音检测)
|
||||
* - 底部:已配置的画面插入列表(可删除)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
|
||||
import type { BRollSegment, BRollInsertMode, PipPosition, SentenceTiming } from "../types"
|
||||
import { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
|
||||
|
||||
interface ModalBRollEditorProps {
|
||||
@@ -18,10 +18,12 @@ interface ModalBRollEditorProps {
|
||||
onClose: () => void
|
||||
/** 当前已有的 B-roll segments(用于标灰已选素材) */
|
||||
existingSegments: BRollSegment[]
|
||||
/** 当前文案全文(用于分句) */
|
||||
/** 文案全文(优先使用对口型时锁定的 scriptText) */
|
||||
scriptText: string
|
||||
/** 对口型成片总时长(秒),用于时间自动估算 */
|
||||
/** 对口型成片总时长(秒) */
|
||||
outputDuration: number
|
||||
/** 后端精确句子时间戳(来自 lipsyncJob.sentence_timings) */
|
||||
sentenceTimings?: SentenceTiming[] | null
|
||||
onConfirm: (segment: BRollSegment) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
@@ -43,7 +45,8 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
onClose,
|
||||
existingSegments,
|
||||
scriptText,
|
||||
outputDuration,
|
||||
outputDuration: _outputDuration,
|
||||
sentenceTimings,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}) => {
|
||||
@@ -62,10 +65,10 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
|
||||
const [pipScale, setPipScale] = useState(0.3)
|
||||
|
||||
/** 文案分句(⑤) */
|
||||
/** 文案分句(优先使用后端精确时间戳,降级为字数比例估算) */
|
||||
const sentences = useMemo(
|
||||
() => splitScriptIntoSentences(scriptText, outputDuration),
|
||||
[scriptText, outputDuration],
|
||||
() => splitScriptIntoSentences(scriptText, sentenceTimings, _outputDuration),
|
||||
[scriptText, sentenceTimings, _outputDuration],
|
||||
)
|
||||
|
||||
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
|
||||
@@ -142,7 +145,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
setSelectedAsset(asset)
|
||||
}
|
||||
|
||||
/** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止) */
|
||||
/** 确认添加一段 B-roll(⑥ 时间取所选句子的精确起止,后端静音检测 / 前端字数比例降级) */
|
||||
const handleConfirm = () => {
|
||||
if (!selectedAsset || !selectedSentence) return
|
||||
const startTime = selectedSentence.startTime
|
||||
@@ -264,11 +267,9 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
>
|
||||
<span className="aa-sentence-item__idx">{sent.index + 1}</span>
|
||||
<span className="aa-sentence-item__text">{sent.text}</span>
|
||||
{outputDuration > 0 && (
|
||||
<span className="aa-sentence-item__time">
|
||||
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
<span className="aa-sentence-item__time">
|
||||
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -349,7 +350,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
selectedSentence.endTime,
|
||||
selectedSentence.startTime + 0.5,
|
||||
).toFixed(1)}
|
||||
s (按字数自动估算)
|
||||
s
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* AI数字人 — 面板5:封面 & 生成
|
||||
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)+ 标题文字实时叠加预览
|
||||
* - 分辨率选择(720p / 1080p / 4K)
|
||||
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
|
||||
* - 渐变紫色生成按钮
|
||||
* AI数字人 — 面板5:分辨率/配置摘要/生成按钮/封面
|
||||
* v3 调整(步骤③④):
|
||||
* - 布局顺序:分辨率 → 配置摘要卡片 → 🔘「开始生成视频」按钮 → (渲染完成后)封面区域
|
||||
* - 渲染未完成时封面区域显示占位态,按钮 disabled
|
||||
* - 「智能获取封面」从最终成片抽帧(调用 POST /renders/{id}/smart-cover),不再依赖 lipsync 状态
|
||||
* - 修复点 2 次 bug:内部维护 smartCoverLoading,不依赖外层异步 state 更新
|
||||
*
|
||||
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
|
||||
*/
|
||||
import React, { useMemo, useRef } from "react"
|
||||
import type { AiAvatarCoverConfig, AiAvatarTitleConfig } from "../types"
|
||||
import React, { useMemo, useRef, useState } from "react"
|
||||
import type { AiAvatarCoverConfig, AiAvatarTitleConfig, RenderJob } from "../types"
|
||||
|
||||
interface PanelCoverAndGenerateProps {
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
@@ -18,10 +19,12 @@ interface PanelCoverAndGenerateProps {
|
||||
onResolutionChange: (r: string) => void
|
||||
isGenerating: boolean
|
||||
onGenerate: () => void
|
||||
/** 智能获取封面(MediaKit 选帧) */
|
||||
onSmartCover: () => void
|
||||
smartCoverLoading: boolean
|
||||
canSmartCover: boolean
|
||||
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */
|
||||
renderJob: RenderJob | null
|
||||
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */
|
||||
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
|
||||
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */
|
||||
onUploadCover?: (file: File) => void
|
||||
/** 配置汇总信息 */
|
||||
summary: {
|
||||
videoName: string | null
|
||||
@@ -30,7 +33,8 @@ interface PanelCoverAndGenerateProps {
|
||||
lipsyncStatus: string | null
|
||||
brollCount: number
|
||||
hasTitle: boolean
|
||||
hasCover: boolean
|
||||
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
|
||||
coverStatus: "not_ready" | "pending" | "selected"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,12 +70,14 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
onResolutionChange,
|
||||
isGenerating,
|
||||
onGenerate,
|
||||
onSmartCover,
|
||||
smartCoverLoading,
|
||||
canSmartCover,
|
||||
renderJob,
|
||||
onGenerateRenderSmartCover,
|
||||
onUploadCover,
|
||||
summary,
|
||||
}) => {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
// 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序)
|
||||
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
|
||||
|
||||
/** 自定义上传封面 */
|
||||
const handleUploadClick = () => {
|
||||
@@ -81,22 +87,44 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
// 本地预览:生成 object URL(实际上传由父级/后端链路处理)
|
||||
const url = URL.createObjectURL(file)
|
||||
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
|
||||
// 允许重复选择同一文件
|
||||
if (onUploadCover) {
|
||||
onUploadCover(file)
|
||||
} else {
|
||||
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
|
||||
const url = URL.createObjectURL(file)
|
||||
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
|
||||
}
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 智能获取封面(调后端 MediaKit 抽帧评分选最佳帧,#1822) */
|
||||
const handleSmartCover = () => {
|
||||
onCoverConfigChange({ mode: "auto_frame" })
|
||||
onSmartCover()
|
||||
/** 智能获取封面(从最终成片抽帧;必须等 render 完成) */
|
||||
const handleSmartCover = async () => {
|
||||
if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return
|
||||
setSmartCoverLoading(true)
|
||||
try {
|
||||
const res = await onGenerateRenderSmartCover(renderJob.id)
|
||||
if (res.cover_url) {
|
||||
onCoverConfigChange({
|
||||
mode: "auto_frame",
|
||||
smart_cover_url: res.cover_url,
|
||||
thumbnail_url: res.cover_url,
|
||||
})
|
||||
} else {
|
||||
// 失败由父组件 message 提示,这里不重复弹窗
|
||||
console.warn("[智能封面] 返回空 cover_url:", res.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[智能封面] 调用失败:", err)
|
||||
} finally {
|
||||
setSmartCoverLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
|
||||
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
|
||||
// 渲染已完成 → 封面区可用
|
||||
const isRenderCompleted = renderJob?.status === "completed"
|
||||
const canSmartCover = isRenderCompleted && !smartCoverLoading
|
||||
|
||||
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
|
||||
const coverUrl =
|
||||
@@ -120,7 +148,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
color: titleConfig.color || "#ffffff",
|
||||
fontSize: `${titleConfig.size}px`,
|
||||
fontSize: `${(titleConfig.size || 48) * 0.35}px`,
|
||||
fontFamily: getFontFamily(titleConfig.font),
|
||||
fontWeight: titleConfig.bold ? "bold" : "normal",
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
@@ -128,7 +156,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
pointerEvents: "none",
|
||||
}
|
||||
|
||||
// 位置
|
||||
const pos = titleConfig.position || "bottom"
|
||||
if (pos === "top") {
|
||||
style.top = "40px"
|
||||
@@ -136,7 +163,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
style.top = "50%"
|
||||
style.transform = "translate(-50%, -50%)"
|
||||
} else if (pos === "custom" && titleConfig.pos_x != null && titleConfig.pos_y != null) {
|
||||
// pos_x/pos_y 是相对预览容器的百分比坐标
|
||||
style.left = `${titleConfig.pos_x}%`
|
||||
style.top = `${titleConfig.pos_y}%`
|
||||
style.transform = "translate(-50%, -50%)"
|
||||
@@ -144,67 +170,35 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
style.bottom = "40px"
|
||||
}
|
||||
|
||||
// 描边优先于阴影(二者互斥,与 drawtext 对齐)
|
||||
if (titleConfig.stroke) {
|
||||
// 描边宽度按字号估算,保证视觉一致
|
||||
const strokeWidth = Math.max(1, Math.round(titleConfig.size / 18))
|
||||
;(style as React.CSSProperties)["WebkitTextStroke"] = `${strokeWidth}px rgba(0,0,0,0.75)`
|
||||
style.textShadow = "none"
|
||||
} else if (titleConfig.shadow) {
|
||||
style.textShadow = "0 2px 8px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.5)"
|
||||
} else {
|
||||
// 默认给轻微阴影保证白字在亮背景可读
|
||||
style.textShadow = "0 2px 6px rgba(0,0,0,0.6)"
|
||||
style.textShadow = "none"
|
||||
}
|
||||
|
||||
return style
|
||||
}, [titleConfig])
|
||||
|
||||
/** 封面区占位文字 */
|
||||
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
|
||||
|
||||
/** 封面摘要状态文本 */
|
||||
const coverSummaryNode = (() => {
|
||||
if (summary.coverStatus === "selected") {
|
||||
return <span className="aa-config-summary__value">已选择</span>
|
||||
}
|
||||
if (summary.coverStatus === "pending") {
|
||||
return <span className="aa-config-summary__value">待选择</span>
|
||||
}
|
||||
return <span className="aa-config-summary__empty">生成视频后可选</span>
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{/* 封面预览(竖屏 9:16) */}
|
||||
<div className="aa-cover-preview">
|
||||
{hasCoverImage ? (
|
||||
<img src={coverUrl!} alt="封面预览" draggable={false} />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">暂无封面</span>
|
||||
)}
|
||||
{/* 智能封面加载遮罩 */}
|
||||
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
|
||||
{/* 标题文字叠加层(实时预览,仅前端视觉参考,最终由后端 ffmpeg drawtext 叠加) */}
|
||||
{showTitleOverlay && (
|
||||
<div style={titleOverlayStyle} aria-hidden="true">
|
||||
{titleConfig.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
|
||||
onClick={handleSmartCover}
|
||||
disabled={smartCoverLoading || !canSmartCover}
|
||||
title={canSmartCover ? "基于对口型成片智能选帧" : "请先完成对口型生成"}
|
||||
>
|
||||
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
📷 自定义上传
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分辨率选择 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">分辨率</label>
|
||||
@@ -212,6 +206,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
className="aa-select"
|
||||
value={resolution}
|
||||
onChange={(e) => onResolutionChange(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{RESOLUTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -272,11 +267,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>封面</span>
|
||||
{summary.hasCover ? (
|
||||
<span className="aa-config-summary__value">已开启</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
{coverSummaryNode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -294,6 +285,60 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
请先完成对口型生成
|
||||
</div>
|
||||
)}
|
||||
{isGenerating && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
|
||||
视频生成中,请稍候…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面区域(视频生成后才激活;步骤③④要求:按钮在封面上方,完成后再显示封面区) */}
|
||||
<div className="aa-cover-section" style={{ marginTop: 16 }}>
|
||||
<div className="aa-label" style={{ marginBottom: 8 }}>
|
||||
封面
|
||||
</div>
|
||||
{/* 封面预览(竖屏 9:16) */}
|
||||
<div className="aa-cover-preview" style={{ opacity: isRenderCompleted ? 1 : 0.5 }}>
|
||||
{hasCoverImage ? (
|
||||
<img src={coverUrl!} alt="封面预览" draggable={false} />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
|
||||
)}
|
||||
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
|
||||
{showTitleOverlay && (
|
||||
<div style={titleOverlayStyle} aria-hidden="true">
|
||||
{titleConfig.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
|
||||
onClick={handleSmartCover}
|
||||
disabled={!canSmartCover}
|
||||
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
|
||||
>
|
||||
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
disabled={!isRenderCompleted || smartCoverLoading}
|
||||
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
|
||||
>
|
||||
📷 自定义上传
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -14,8 +14,8 @@ interface PanelLipsyncPreviewProps {
|
||||
onRemoveBRoll: (id: string) => void
|
||||
/** 标题配置(实时叠加预览用) */
|
||||
titleConfig?: AiAvatarTitleConfig
|
||||
/** 标题位置变更回调(拖拽结束时调用) */
|
||||
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number }) => void
|
||||
/** 标题位置变更回调(拖拽结束时调用,发送百分比坐标 + position:"custom") */
|
||||
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number; position: string }) => void
|
||||
}
|
||||
|
||||
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
|
||||
@@ -56,11 +56,9 @@ export function PanelLipsyncPreview({
|
||||
const titleOverlayStyle: React.CSSProperties | null = titleConfig?.title
|
||||
? {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
color: titleConfig.color || "#ffffff",
|
||||
fontFamily: titleConfig.font || "思源黑体",
|
||||
fontSize: `${(titleConfig.size || 36) * 0.55}px`, // 预览等比缩
|
||||
fontSize: `${(titleConfig.size || 48) * 0.35}px`,
|
||||
fontWeight: titleConfig.bold ? 700 : 400,
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
textAlign: "center",
|
||||
@@ -68,11 +66,19 @@ export function PanelLipsyncPreview({
|
||||
padding: "4px 8px",
|
||||
textShadow: titleConfig.shadow ? "0 2px 4px rgba(0,0,0,0.8)" : undefined,
|
||||
WebkitTextStroke: titleConfig.stroke ? "1.5px #000" : undefined,
|
||||
...(titleConfig.position === "top"
|
||||
? { top: 8 }
|
||||
: titleConfig.position === "bottom"
|
||||
? { bottom: 8 }
|
||||
: { top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
|
||||
...(titleConfig.position === "custom" &&
|
||||
titleConfig.pos_x != null &&
|
||||
titleConfig.pos_y != null
|
||||
? {
|
||||
left: `${titleConfig.pos_x}%`,
|
||||
top: `${titleConfig.pos_y}%`,
|
||||
transform: "translateX(-50%) translateY(-50%)",
|
||||
}
|
||||
: titleConfig.position === "top"
|
||||
? { left: "50%", top: 8, transform: "translateX(-50%)" }
|
||||
: titleConfig.position === "bottom"
|
||||
? { left: "50%", bottom: 8, transform: "translateX(-50%)" }
|
||||
: { left: "50%", top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
|
||||
}
|
||||
: null
|
||||
|
||||
@@ -105,7 +111,10 @@ export function PanelLipsyncPreview({
|
||||
const rect = previewContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
onTitlePositionChange({ pos_x: relX, pos_y: relY })
|
||||
// 发送百分比坐标(0-100),与后端 drawtext 百分比表达式对齐
|
||||
const xpct = Math.round((relX / rect.width) * 1000) / 10
|
||||
const ypct = Math.round((relY / rect.height) * 1000) / 10
|
||||
onTitlePositionChange({ pos_x: xpct, pos_y: ypct, position: "custom" })
|
||||
}
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||||
}
|
||||
|
||||
@@ -44,12 +44,23 @@ export interface LipsyncJob {
|
||||
status: LipsyncStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
/** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
|
||||
/** 对口型成片总时长(秒),后端返回 */
|
||||
script_text: string
|
||||
output_duration?: number
|
||||
/** 精确句子时间戳(后端基于 TTS 音频静音检测计算) */
|
||||
sentence_timings?: SentenceTiming[] | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/* ── 句子时间戳(后端精确计算) ── */
|
||||
export interface SentenceTiming {
|
||||
index: number
|
||||
text: string
|
||||
start_time: number
|
||||
end_time: number
|
||||
}
|
||||
|
||||
/* ── B-roll 画面插入 ── */
|
||||
export type BRollInsertMode = "fullscreen" | "pip"
|
||||
export type PipPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"
|
||||
@@ -77,7 +88,7 @@ export interface AiAvatarTitleConfig {
|
||||
shadow: boolean
|
||||
color: string
|
||||
auto_subtitle: boolean
|
||||
/** 自定义位置坐标(position=custom 时生效,像素) */
|
||||
/** 自定义位置坐标(position=custom 时生效,百分比 0-100) */
|
||||
pos_x?: number
|
||||
pos_y?: number
|
||||
}
|
||||
@@ -101,6 +112,7 @@ export interface RenderJob {
|
||||
status: RenderStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
output_cover_url: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
@@ -110,7 +122,7 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
|
||||
title: "",
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
size: 48,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
|
||||
@@ -28,10 +28,13 @@ export function normalizeEmotion(raw: string | undefined | null): VoiceEmotion {
|
||||
* 后端真实字段:text(或content)、font(或font_preset)、font_size(或size)、
|
||||
* font_color(或color,可传 #RRGGBB)、position(top/center/bottom/custom)、
|
||||
* enabled、bold、stroke{enabled,width,color}、shadow{enabled,color,offset_x,offset_y}、
|
||||
* pos_x/pos_y(custom 时)。
|
||||
* pos_x/pos_y(custom 时)、title_image_dataurl(前端 Canvas 渲染的 PNG dataURL,WYSIWYG 路径优先)。
|
||||
* 口播标题默认 position=bottom(不传后端会默认 top 跑到画面顶部)。
|
||||
*/
|
||||
export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string, unknown> {
|
||||
export function buildTitleConfigPayload(
|
||||
cfg: AiAvatarTitleConfig,
|
||||
titleImageDataUrl?: string | null,
|
||||
): Record<string, unknown> {
|
||||
const text = (cfg.title || "").trim()
|
||||
if (!text) return {}
|
||||
const position = cfg.position || "bottom"
|
||||
@@ -39,7 +42,7 @@ export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string
|
||||
text,
|
||||
enabled: true,
|
||||
font: cfg.font || "思源黑体",
|
||||
font_size: Math.round(cfg.size) || 36,
|
||||
font_size: Math.round(cfg.size) || 48,
|
||||
font_color: cfg.color || "#ffffff",
|
||||
position,
|
||||
bold: !!cfg.bold,
|
||||
@@ -53,6 +56,10 @@ export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string
|
||||
payload.pos_x = cfg.pos_x
|
||||
payload.pos_y = cfg.pos_y
|
||||
}
|
||||
// 前端 Canvas 渲染好的 PNG dataURL(所见即所得,后端优先 overlay 此图片图层)
|
||||
if (titleImageDataUrl) {
|
||||
payload.title_image_dataurl = titleImageDataUrl
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -67,9 +74,14 @@ export function buildCoverConfigPayload(
|
||||
// build_cover_extract_command 读取 timestamp(截帧秒数)
|
||||
timestamp: cfg.frame_time || 0,
|
||||
}
|
||||
if (smartCoverUrl) payload.cover_url = smartCoverUrl
|
||||
// 智能封面 URL(后端字段名为 url/imageUrl/cover_url 都兼容,优先 url)
|
||||
if (smartCoverUrl) {
|
||||
payload.url = smartCoverUrl
|
||||
payload.cover_url = smartCoverUrl
|
||||
}
|
||||
// 自定义上传:blob: 本地预览地址无法给后端,仅 OSS URL 可用
|
||||
if (cfg.mode === "upload" && cfg.upload_url && !cfg.upload_url.startsWith("blob:")) {
|
||||
payload.url = cfg.upload_url
|
||||
payload.upload_url = cfg.upload_url
|
||||
}
|
||||
return payload
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* AI数字人 — 文案分句 & B-roll 时间自动估算(#1809 ⑤⑥)
|
||||
* AI数字人 — 文案分句 & B-roll 时间计算
|
||||
*
|
||||
* 数据来源优先级:
|
||||
* 1. 后端 sentence_timings(基于 TTS 音频静音检测,精确到句子边界)—— 直接使用,不重新分句
|
||||
* 2. 后端 output_duration(最终渲染视频时长) + 本地分句 —— 按字数比例估算
|
||||
* 3. 两者都没有(对口型还在生成中)—— 返回分句文本但 startTime/endTime 全部 0,等数据到位重算
|
||||
*/
|
||||
|
||||
export interface ScriptSentence {
|
||||
@@ -11,25 +16,67 @@ export interface ScriptSentence {
|
||||
charCount: number
|
||||
/** 累计起始字数(用于时间估算) */
|
||||
startChar: number
|
||||
/** 估算的对口型视频内起始时间(秒) */
|
||||
/** 对口型视频内起始时间(秒)——后端精确值或前端估算 */
|
||||
startTime: number
|
||||
/** 估算的对口型视频内结束时间(秒) */
|
||||
/** 对口型视频内结束时间(秒)——后端精确值或前端估算 */
|
||||
endTime: number
|
||||
}
|
||||
|
||||
/** 句子分隔符:中英文句号/问号/感叹号/分号/逗号/换行(覆盖中文短视频常用断句) */
|
||||
const SENTENCE_SPLIT_RE = /[。!?!??!;;,,\n\r]+/
|
||||
|
||||
/**
|
||||
* 按句号/问号/感叹号/分号/换行分句(兼容中英文标点)。
|
||||
* 空文案返回空数组。时间按「该句字数 ÷ 全文总字数 × 口播总时长」线性估算。
|
||||
* 分句并计算每句的起止时间。
|
||||
*
|
||||
* @param sentenceTimings 后端返回的精确句子时间戳(来自 lipsync_job.sentence_timings)。
|
||||
* 非空时直接按后端返回的句子列表渲染,不再本地分句(避免前后端分句不一致导致时间错位)。
|
||||
* @param outputDuration 最终视频时长(秒)。对口型预览阶段可能为 0,此时降级估算只能给 0。
|
||||
*/
|
||||
export function splitScriptIntoSentences(
|
||||
scriptText: string,
|
||||
outputDuration: number,
|
||||
sentenceTimings?:
|
||||
{ index?: number; text?: string; start_time: number; end_time: number }[] | null,
|
||||
outputDuration: number = 0,
|
||||
): ScriptSentence[] {
|
||||
const text = (scriptText || "").trim()
|
||||
if (!text) return []
|
||||
|
||||
// 1. 后端返回了 sentence_timings:校验通过就直接用,跳过本地分句
|
||||
// 校验条件放宽:只要是数组、至少1条、每条 start_time/end_time 是数字即可
|
||||
// (不再强制要求条数相等——后端静音检测可能按停顿切出更多/更少边界,
|
||||
// 比如文案用逗号连写时本地只分1句、后端按停顿切4句,后端的切法才是对的)
|
||||
if (Array.isArray(sentenceTimings) && sentenceTimings.length > 0) {
|
||||
const valid = sentenceTimings.every(
|
||||
(t) =>
|
||||
t &&
|
||||
typeof t.start_time === "number" &&
|
||||
typeof t.end_time === "number" &&
|
||||
isFinite(t.start_time) &&
|
||||
isFinite(t.end_time) &&
|
||||
t.end_time >= t.start_time,
|
||||
)
|
||||
if (valid) {
|
||||
let accChar = 0
|
||||
return sentenceTimings.map((t, i) => {
|
||||
const sentenceText = (t.text || "").trim() || `句子${i + 1}`
|
||||
const charCount = sentenceText.replace(/\s/g, "").length
|
||||
const sentence: ScriptSentence = {
|
||||
index: typeof t.index === "number" ? t.index : i,
|
||||
text: sentenceText,
|
||||
charCount,
|
||||
startChar: accChar,
|
||||
startTime: round1(t.start_time),
|
||||
endTime: round1(t.end_time),
|
||||
}
|
||||
accChar += charCount
|
||||
return sentence
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 本地分句 + 按字数比例估算(降级路径)
|
||||
const rawParts = text
|
||||
.split(/[。!?!?;;\n\r]+/)
|
||||
.split(SENTENCE_SPLIT_RE)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* AI数字人 — 标题 Canvas 渲染工具
|
||||
*
|
||||
* 把标题按前端预览的 HTML/CSS 效果画到透明背景 PNG 上(与视频同分辨率),
|
||||
* 以 dataURL 形式传给后端,后端用 FFmpeg overlay 直接叠加图层,
|
||||
* 彻底解决前端 HTML/CSS 预览 ≠ FFmpeg drawtext 成片的 WYSIWYG 问题。
|
||||
*/
|
||||
import type { AiAvatarTitleConfig } from "../types"
|
||||
|
||||
export interface RenderTitlePngOptions {
|
||||
/** 标题配置 */
|
||||
titleConfig: AiAvatarTitleConfig
|
||||
/** 视频宽度(像素),默认 720 */
|
||||
videoWidth?: number
|
||||
/** 视频高度(像素),默认 1280 */
|
||||
videoHeight?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 将标题渲染为透明背景 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
|
||||
.split(/[//]/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
if (lines.length === 0) return null
|
||||
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = videoWidth
|
||||
canvas.height = videoHeight
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return null
|
||||
|
||||
const size = Math.max(12, Math.round(titleConfig.size || 48))
|
||||
const bold = !!titleConfig.bold
|
||||
const italic = !!titleConfig.italic
|
||||
const color = titleConfig.color || "#ffffff"
|
||||
const stroke = !!titleConfig.stroke
|
||||
const shadow = !!titleConfig.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(" ")
|
||||
ctx.fillStyle = color
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
|
||||
// 阴影(shadow=true 时开启)
|
||||
if (shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.8)"
|
||||
ctx.shadowBlur = 4
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 2
|
||||
}
|
||||
|
||||
// 位置计算:与 PanelLipsyncPreview 的 CSS 对齐
|
||||
// 预览用 top/bottom 8px padding + transform translateX(-50%) 居中;
|
||||
// 这里画到整尺寸 canvas,padding 按比例放大到全分辨率(预览缩放 0.35x 时 8px ≈ 23px 全尺寸,
|
||||
// 为更贴近原 CSS 16px 安全边距,用 16px 作为内边距)。
|
||||
const PAD = 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 时首行基线)
|
||||
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
|
||||
firstLineY = centerY - totalTextH / 2 + size / 2
|
||||
} else if (position === "top") {
|
||||
// 顶部:y = size/2 + PAD
|
||||
firstLineY = size / 2 + PAD
|
||||
} else if (position === "center") {
|
||||
firstLineY = videoHeight / 2 - totalTextH / 2 + size / 2
|
||||
} else {
|
||||
// bottom(默认)
|
||||
firstLineY = videoHeight - totalTextH - PAD + size / 2
|
||||
}
|
||||
|
||||
// 描边参数(stroke=true 或 bold 默认细描边模拟粗体时都画;
|
||||
// 注意:浏览器原生 bold 已经是粗体 glyph,Canvas 这里对 stroke=true 才加黑描边,
|
||||
// 与预览 CSS 的 WebkitTextStroke 保持一致,不对 bold 自动加描边避免双粗)。
|
||||
const doStroke = stroke
|
||||
// 逐行绘制
|
||||
lines.forEach((line, idx) => {
|
||||
const y = firstLineY + idx * lineGap
|
||||
if (doStroke) {
|
||||
const prevShadowColor = ctx.shadowColor
|
||||
const prevShadowBlur = ctx.shadowBlur
|
||||
// 描边不要带阴影(避免黑色描边发虚)
|
||||
ctx.shadowColor = "rgba(0,0,0,0)"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.lineWidth = Math.max(2, size * 0.06)
|
||||
ctx.strokeStyle = "#000000"
|
||||
ctx.lineJoin = "round"
|
||||
ctx.strokeText(line, centerX, y)
|
||||
// 恢复阴影
|
||||
if (shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.8)"
|
||||
ctx.shadowBlur = 4
|
||||
} else {
|
||||
ctx.shadowColor = prevShadowColor
|
||||
ctx.shadowBlur = prevShadowBlur
|
||||
}
|
||||
}
|
||||
ctx.fillText(line, centerX, y)
|
||||
})
|
||||
|
||||
try {
|
||||
return canvas.toDataURL("image/png")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频真实分辨率(HTMLVideoElement + loadedmetadata,超时 3 秒兜底 720×1280)。
|
||||
*/
|
||||
export function getVideoResolution(
|
||||
videoUrl: string,
|
||||
timeoutMs = 3000,
|
||||
): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve) => {
|
||||
if (!videoUrl) {
|
||||
resolve({ width: 720, height: 1280 })
|
||||
return
|
||||
}
|
||||
const video = document.createElement("video")
|
||||
video.preload = "metadata"
|
||||
video.muted = true
|
||||
video.playsInline = true
|
||||
video.crossOrigin = "anonymous"
|
||||
let settled = false
|
||||
const done = (w: number, h: number) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
video.removeAttribute("src")
|
||||
video.load()
|
||||
resolve({ width: w, height: h })
|
||||
}
|
||||
const timer = window.setTimeout(() => done(720, 1280), timeoutMs)
|
||||
video.onloadedmetadata = () => {
|
||||
window.clearTimeout(timer)
|
||||
const w = video.videoWidth || 720
|
||||
const h = video.videoHeight || 1280
|
||||
done(w, h)
|
||||
}
|
||||
video.onerror = () => {
|
||||
window.clearTimeout(timer)
|
||||
done(720, 1280)
|
||||
}
|
||||
video.src = videoUrl
|
||||
})
|
||||
}
|
||||
@@ -703,6 +703,9 @@ class LipsyncJobModel(Base):
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
error_code = Column(String(100), nullable=False, default="")
|
||||
|
||||
# 精确句子时间戳(TTS 合成后由 silencedetect 计算,用于 B-roll 精确定位)
|
||||
sentence_timings = Column(JSON, nullable=True) # list[{index,text,start_time,end_time}]
|
||||
|
||||
# 时间戳
|
||||
submitted_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -420,6 +420,11 @@ def _escape_drawtext_text(text: str) -> str:
|
||||
return result
|
||||
|
||||
|
||||
# 粗体字体已由前端 Canvas 直接渲染(Canvas 使用浏览器原生粗体 glyph),
|
||||
# FFmpeg 侧不再需要查找 Bold 字体文件;drawtext 仅作为旧版前端的降级路径,
|
||||
# 通过 borderw 黑色细描边模拟粗体(见 build_title_drawtext_filter)。
|
||||
|
||||
|
||||
def _resolve_font_path(font_name: str) -> str:
|
||||
"""解析字体名到服务器实际字体文件路径。
|
||||
|
||||
@@ -427,6 +432,9 @@ def _resolve_font_path(font_name: str) -> str:
|
||||
1. 通过 DRAWTEXT_FONT_MAP 映射前端字体名到服务器关键字
|
||||
2. 在 DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径
|
||||
3. 未找到则返回空字符串(drawtext 使用内置默认字体)
|
||||
|
||||
注:粗体已由前端 Canvas 渲染时直接用浏览器 bold glyph 绘制,
|
||||
此处仅作为旧版前端降级路径,无需切换 Bold 字体文件。
|
||||
"""
|
||||
keyword = DRAWTEXT_FONT_MAP.get(font_name, font_name)
|
||||
import os
|
||||
@@ -470,8 +478,10 @@ def build_title_drawtext_filter(
|
||||
if not title_config or not isinstance(title_config, dict):
|
||||
return None
|
||||
|
||||
# 字段名归一化:兼容 content/text、font_preset/font 两套命名
|
||||
text = (title_config.get("text") or title_config.get("content") or "").strip()
|
||||
# 字段名归一化:兼容 content/text/title 三套命名
|
||||
text = (
|
||||
title_config.get("text") or title_config.get("content") or title_config.get("title") or ""
|
||||
).strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
@@ -481,13 +491,13 @@ def build_title_drawtext_filter(
|
||||
|
||||
# ── 样式参数 ──
|
||||
font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体"
|
||||
font_size = int(title_config.get("font_size") or title_config.get("size") or 36)
|
||||
font_size = int(title_config.get("font_size") or title_config.get("size") or 48)
|
||||
font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff"
|
||||
# 去掉 # 前缀(drawtext 用纯 hex 或颜色名)
|
||||
if font_color.startswith("#"):
|
||||
font_color = font_color[1:]
|
||||
|
||||
position = title_config.get("position", "top")
|
||||
position = title_config.get("position") or "bottom"
|
||||
bold = bool(title_config.get("bold", True))
|
||||
stroke = title_config.get("stroke")
|
||||
shadow = title_config.get("shadow")
|
||||
@@ -495,7 +505,7 @@ def build_title_drawtext_filter(
|
||||
# ── 构建 drawtext 参数 ──
|
||||
params: list[str] = []
|
||||
|
||||
# 字体文件
|
||||
# 字体文件(drawtext 降级路径:粗体通过 borderw 黑色描边模拟)
|
||||
font_path = _resolve_font_path(font_name)
|
||||
if font_path:
|
||||
escaped_path = font_path.replace("\\", "\\\\").replace(":", "\\\\:").replace("'", "\\\\'")
|
||||
@@ -508,13 +518,11 @@ def build_title_drawtext_filter(
|
||||
params.append(f"fontsize={font_size}")
|
||||
params.append(f"fontcolor={font_color}")
|
||||
|
||||
# 粗体:drawtext 没有独立的 bold 参数,通过加大 borderw 模拟视觉粗体效果。
|
||||
# 注意:不能使用 `font=bold`——FFmpeg drawtext 的 font 参数需要 fontconfig 能解析的
|
||||
# 字体族名,而 "bold" 不是合法族名,会导致整个 filter_complex 解析失败(exit code 234)。
|
||||
# 当用户未显式配置描边宽度时,bold 模式自动将 borderw 提升到 3 以模拟粗体。
|
||||
|
||||
# 描边(borderw 需要 libfreetype 支持)
|
||||
# 粗体无显式描边时,自动用 borderw=3 + 近色描边模拟粗体;显式 stroke 按用户配置走
|
||||
# 之前用 borderw=3 + font_color 同色描边模拟粗体,会在小字号/竖屏视频上造成
|
||||
# 字形偏移、边缘重影,看起来像文字被打印了两次(用户截图中的标题"曝光曝光…")。
|
||||
# 修复:粗体改用黑色细描边(borderw=2, 黑色),视觉上清晰加粗且不产生偏移。
|
||||
# 用户显式开启 stroke 时按用户配置走;粗体+无stroke 默认黑色细描边。
|
||||
border_width = 0
|
||||
border_color = "000000"
|
||||
if stroke:
|
||||
@@ -526,9 +534,9 @@ def build_title_drawtext_filter(
|
||||
border_width = int(stroke.get("width", 2))
|
||||
border_color = (stroke.get("color") or "#000000").lstrip("#")
|
||||
elif bold:
|
||||
# 粗体模式且未配描边:加大描边宽度模拟粗体效果
|
||||
border_width = 3
|
||||
border_color = font_color # 用字体同色描边,视觉上加粗字形而非黑边
|
||||
# 粗体模式且未配描边:黑色细描边,模拟粗体同时保证不重影
|
||||
border_width = 2
|
||||
border_color = "000000"
|
||||
if border_width > 0:
|
||||
params.append(f"borderw={border_width}")
|
||||
params.append(f"bordercolor={border_color}")
|
||||
@@ -556,8 +564,13 @@ def build_title_drawtext_filter(
|
||||
and not isinstance(pos_x, bool)
|
||||
and not isinstance(pos_y, bool)
|
||||
):
|
||||
params.append(f"x={int(pos_x)}")
|
||||
params.append(f"y={int(pos_y)}")
|
||||
# pos_x/pos_y 为百分比坐标(0-100),转换为 drawtext 表达式
|
||||
# 例如 pos_x=50 → x=(w-text_w)*0.50(水平居中偏50%)
|
||||
# pos_y=30 → y=(h-text_h)*0.30
|
||||
pct_x = max(0.0, min(100.0, float(pos_x))) / 100.0
|
||||
pct_y = max(0.0, min(100.0, float(pos_y))) / 100.0
|
||||
params.append(f"x=(w-text_w)*{pct_x:.4f}")
|
||||
params.append(f"y=(h-text_h)*{pct_y:.4f}")
|
||||
else:
|
||||
# 三档预设位置:top / center / bottom
|
||||
# x 始终水平居中:(w-text_w)/2
|
||||
@@ -573,6 +586,43 @@ def build_title_drawtext_filter(
|
||||
return "drawtext=" + ":".join(params)
|
||||
|
||||
|
||||
def build_title_overlay_filter(
|
||||
title_config: dict[str, Any],
|
||||
output_width: int, # noqa: ARG001 - 保留参数签名,PNG 已按视频分辨率绘制
|
||||
output_height: int, # noqa: ARG001
|
||||
title_png_path: str,
|
||||
*,
|
||||
title_input_label: str = "[1:v]",
|
||||
base_label: str = "[0:v]",
|
||||
output_label: str = "vout_titled",
|
||||
) -> str | None:
|
||||
"""构建标题 PNG 图层 overlay 滤镜(WYSIWYG 路径)。
|
||||
|
||||
前端用 Canvas 把标题画成与视频同分辨率的透明 PNG(所见即所得),
|
||||
后端直接 overlay=0:0 叠加即可,PNG 透明区域不遮挡视频。
|
||||
|
||||
Args:
|
||||
title_config: 标题配置 dict(仅用来判断降级)
|
||||
output_width: 输出宽度(未使用,PNG 已按该分辨率绘制)
|
||||
output_height: 输出高度(未使用)
|
||||
title_png_path: 已保存到本地的标题 PNG 文件路径
|
||||
title_input_label: 标题 PNG 在 filter_complex 中的输入标签(默认 "[1:v]")
|
||||
base_label: 前序滤镜输出标签(如 B-roll 输出 "[vout]")
|
||||
output_label: overlay 输出标签名
|
||||
|
||||
Returns:
|
||||
overlay 滤镜字符串;title_png_path 为空/文件不存在时返回 None(降级到 drawtext)
|
||||
"""
|
||||
import os
|
||||
|
||||
if not title_png_path or not os.path.isfile(title_png_path):
|
||||
return None
|
||||
if not title_config or not isinstance(title_config, dict):
|
||||
return None
|
||||
|
||||
return f"{base_label}{title_input_label}overlay=0:0[{output_label}]"
|
||||
|
||||
|
||||
# ── B-roll 叠加滤镜 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -695,7 +745,9 @@ def _build_fullscreen_filters(
|
||||
for idx, seg in enumerate(sorted_fs_segments):
|
||||
start = seg.get("start_time", 0)
|
||||
# 每段 B-roll 之前是否有主视频片段?
|
||||
has_main_before = (idx == 0 and start > 0) or (idx > 0 and sorted_fs_segments[idx - 1].get("end_time", 0) < start)
|
||||
has_main_before = (idx == 0 and start > 0) or (
|
||||
idx > 0 and sorted_fs_segments[idx - 1].get("end_time", 0) < start
|
||||
)
|
||||
if has_main_before:
|
||||
segment_labels.append(f"[main{idx}]")
|
||||
segment_labels.append(f"[br{idx}]")
|
||||
@@ -765,7 +817,6 @@ def _build_pip_filters(
|
||||
return "".join(parts), cur_label or "vout"
|
||||
|
||||
|
||||
|
||||
def build_cover_extract_command(
|
||||
cover_config: dict[str, Any],
|
||||
output_path: str,
|
||||
|
||||
@@ -318,3 +318,120 @@ class TestBrollOverlayFilter:
|
||||
"/tmp/cover.jpg",
|
||||
)
|
||||
assert "scale=" in cmd
|
||||
|
||||
|
||||
def _make_mock_auth_user(user_id="user-1"):
|
||||
"""构造 AuthenticatedUser:current_user.user.id."""
|
||||
auth = MagicMock()
|
||||
auth.user.id = user_id
|
||||
return auth
|
||||
|
||||
|
||||
class TestRenderSmartCoverRoute:
|
||||
"""POST /renders/{job_id}/smart-cover — 从成片智能抽封面(步骤②)."""
|
||||
|
||||
def test_smart_cover_job_not_found_returns_404(self):
|
||||
"""渲染任务不存在 → 404."""
|
||||
from app.api.routes.ai_avatar_render import generate_render_smart_cover
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.get_render_job.return_value = None
|
||||
mock_db = MagicMock()
|
||||
mock_user = _make_mock_auth_user()
|
||||
|
||||
# 函数内部 `from app.services.ai_avatar_render_service import AiAvatarRenderService`
|
||||
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
generate_render_smart_cover(job_id="render-missing", current_user=mock_user, db=mock_db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "不存在" in exc_info.value.detail
|
||||
mock_service.get_render_job.assert_called_once_with("render-missing", "user-1")
|
||||
|
||||
def test_smart_cover_job_not_completed_returns_400(self):
|
||||
"""任务未 completed(如 processing)→ 400."""
|
||||
from app.api.routes.ai_avatar_render import generate_render_smart_cover
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_job = _make_mock_render_job(status="processing", output_video_url="https://oss/video.mp4")
|
||||
mock_service.get_render_job.return_value = mock_job
|
||||
mock_db = MagicMock()
|
||||
mock_user = _make_mock_auth_user()
|
||||
|
||||
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "先完成视频生成" in exc_info.value.detail
|
||||
|
||||
def test_smart_cover_empty_video_url_returns_400(self):
|
||||
"""已 completed 但 output_video_url 为空/空白 → 400."""
|
||||
from app.api.routes.ai_avatar_render import generate_render_smart_cover
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_job = _make_mock_render_job(status="completed", output_video_url=" ")
|
||||
mock_service.get_render_job.return_value = mock_job
|
||||
mock_db = MagicMock()
|
||||
mock_user = _make_mock_auth_user()
|
||||
|
||||
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "URL 为空" in exc_info.value.detail
|
||||
|
||||
def test_smart_cover_success_updates_db_and_returns_url(self):
|
||||
"""抽帧成功 → 更新 job.cover_config / output_cover_url 并 commit,返回 completed."""
|
||||
from app.api.routes.ai_avatar_render import generate_render_smart_cover
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_job = _make_mock_render_job(
|
||||
status="completed",
|
||||
output_video_url="https://oss/final.mp4",
|
||||
)
|
||||
mock_job.cover_config = {"mode": "manual"}
|
||||
mock_service.get_render_job.return_value = mock_job
|
||||
mock_db = MagicMock()
|
||||
mock_user = _make_mock_auth_user()
|
||||
|
||||
with (
|
||||
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
|
||||
patch(
|
||||
"app.api.routes.ai_avatar_render.generate_smart_cover", return_value="https://oss/cover.jpg"
|
||||
) as mock_gen,
|
||||
):
|
||||
result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
|
||||
|
||||
mock_gen.assert_called_once_with("https://oss/final.mp4", job_id="render-1", max_frames=5)
|
||||
assert result.status == "completed"
|
||||
assert result.cover_url == "https://oss/cover.jpg"
|
||||
assert mock_job.output_cover_url == "https://oss/cover.jpg"
|
||||
assert mock_job.cover_config["mode"] == "auto_frame"
|
||||
assert mock_job.cover_config["url"] == "https://oss/cover.jpg"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_smart_cover_extract_failure_returns_fallback_failed(self):
|
||||
"""generate_smart_cover 抛异常 → fallback_failed,不抛错不写 DB."""
|
||||
from app.api.routes.ai_avatar_render import generate_render_smart_cover
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_job = _make_mock_render_job(status="completed", output_video_url="https://oss/final.mp4")
|
||||
mock_service.get_render_job.return_value = mock_job
|
||||
mock_db = MagicMock()
|
||||
mock_user = _make_mock_auth_user()
|
||||
|
||||
with (
|
||||
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
|
||||
patch("app.api.routes.ai_avatar_render.generate_smart_cover", side_effect=RuntimeError("mediakit down")),
|
||||
):
|
||||
result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
|
||||
|
||||
assert result.status == "fallback_failed"
|
||||
assert result.cover_url == ""
|
||||
# 失败时不写 cover_config / 不 commit
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
@@ -628,3 +628,58 @@ class TestAiAvatarRenderService:
|
||||
err = AiAvatarRenderError("测试错误", code="TestCode")
|
||||
assert err.code == "TestCode"
|
||||
assert str(err) == "测试错误"
|
||||
|
||||
|
||||
class TestAiAvatarRenderCoverPassthrough:
|
||||
"""execute_render 中封面透传逻辑(320~329 行):cover_config 含 url/imageUrl/cover_url 时直接透传到 output_cover_url."""
|
||||
|
||||
def _run_execute(self, mock_job, mock_lipsync_job):
|
||||
"""驱动 execute_render 跑到完成阶段的通用脚手架(mock IO 部分)."""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
mock_filter = MagicMock()
|
||||
# query.filter 返回同一个 filter 两次(render_job 查询、lipsync 查询)
|
||||
mock_filter.first.side_effect = [mock_job, mock_lipsync_job]
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
with (
|
||||
patch.object(svc, "_download_video", return_value="/tmp/video.mp4"),
|
||||
patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"),
|
||||
patch("subprocess.run") as mock_run,
|
||||
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
|
||||
patch("app.services.ai_avatar_cover_service.generate_smart_cover", return_value=""),
|
||||
patch("packages.domain.generated_video.GeneratedVideo.create", return_value=MagicMock()),
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
||||
) as repo_cls,
|
||||
):
|
||||
import subprocess as _sp
|
||||
|
||||
mock_run.return_value = _sp.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||
import tempfile as _tf
|
||||
|
||||
tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir")
|
||||
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
|
||||
repo_cls.return_value = MagicMock()
|
||||
svc.execute_render(mock_job.id)
|
||||
return mock_db, mock_job
|
||||
|
||||
def test_cover_url_in_cover_config_passthrough_to_output_cover(self):
|
||||
"""cover_config.url 存在 → 透传到 output_cover_url."""
|
||||
mock_job = _make_mock_render_job(job_id="render-cov-1", status="pending")
|
||||
mock_job.cover_config = {"mode": "upload", "url": "https://oss/user-cover.jpg"}
|
||||
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0)
|
||||
_, job = self._run_execute(mock_job, mock_lipsync_job)
|
||||
assert job.output_cover_url == "https://oss/user-cover.jpg"
|
||||
|
||||
def test_cover_imageurl_fallback_also_passthrough(self):
|
||||
"""cover_config.imageUrl(老字段)存在 → 也透传到 output_cover_url."""
|
||||
mock_job = _make_mock_render_job(job_id="render-cov-2", status="pending")
|
||||
mock_job.cover_config = {"mode": "upload", "imageUrl": "https://oss/user-cover2.jpg"}
|
||||
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0)
|
||||
_, job = self._run_execute(mock_job, mock_lipsync_job)
|
||||
assert job.output_cover_url == "https://oss/user-cover2.jpg"
|
||||
|
||||
@@ -288,3 +288,47 @@ class TestCancelJobTtsProcessing:
|
||||
|
||||
result = svc.cancel_job("job-1", "user-1")
|
||||
assert result.status == "cancelled"
|
||||
|
||||
|
||||
class TestCreateJobCommitOrder:
|
||||
"""验证事务顺序修复:create_job 必须先 commit 再发 Celery 任务,避免 worker 消费时 job 不可见。"""
|
||||
|
||||
def test_commit_called_before_apply_async_in_tts_mode(self):
|
||||
"""TTS 模式:db.commit() 必须在 apply_async() 之前调用,防止 worker 查不到 job 永远卡在 tts_processing。"""
|
||||
svc, client, cosy = _make_service_with_mocks()
|
||||
call_order: list[str] = []
|
||||
|
||||
def track_commit():
|
||||
call_order.append("commit")
|
||||
|
||||
def track_apply_async(*args, **kwargs):
|
||||
call_order.append("apply_async")
|
||||
|
||||
svc.db.commit.side_effect = track_commit
|
||||
|
||||
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
|
||||
mock_task.apply_async = MagicMock(side_effect=track_apply_async)
|
||||
svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
voice_id="v-1",
|
||||
script_text="测试",
|
||||
)
|
||||
|
||||
# 至少有一次 commit 在 apply_async 之前
|
||||
assert "commit" in call_order, "db.commit 必须被调用"
|
||||
assert "apply_async" in call_order, "apply_async 必须被调用"
|
||||
assert call_order.index("commit") < call_order.index(
|
||||
"apply_async"
|
||||
), f"事务顺序错误:commit 必须在 apply_async 之前,实际顺序 {call_order}"
|
||||
|
||||
def test_job_not_found_retry_mechanism_exists(self):
|
||||
"""worker 侧 job not found 必须有重试机制(self.retry),而不是静默 return。"""
|
||||
import inspect
|
||||
|
||||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||||
|
||||
source = inspect.getsource(tts_synthesize_and_submit.run)
|
||||
assert (
|
||||
"self.retry" in source or "retry" in source
|
||||
), "tts_synthesize_and_submit 在 job not found 时必须重试,防止静默失败"
|
||||
|
||||
@@ -185,7 +185,10 @@ class TestTtsSynthesizeAndSubmit:
|
||||
mk_client.submit_lipsync.assert_called_once()
|
||||
call_kwargs = mk_client.submit_lipsync.call_args.kwargs
|
||||
assert call_kwargs["client_token"] == "job-1"
|
||||
assert call_kwargs["audio_url"].endswith("?signed")
|
||||
# CosyVoice 临时 URL 经 _sign_media_url 透传(mock 统一追加 ?signed),
|
||||
# 自家 OSS 才会被重签,外部 URL 原样透传;job.audio_url 存原始临时 URL
|
||||
assert call_kwargs["audio_url"] == "https://tts/raw.mp3?signed"
|
||||
assert job.audio_url == "https://tts/raw.mp3"
|
||||
session.commit.assert_called()
|
||||
session.close.assert_called_once()
|
||||
|
||||
@@ -387,3 +390,239 @@ class TestSignMediaUrl:
|
||||
|
||||
assert result == "https://anything.example.com/a.mp3"
|
||||
fake_storage.get_download_url.assert_not_called()
|
||||
|
||||
|
||||
class TestPersistOutputVideoTask:
|
||||
"""persist_output_video_task:下载 MediaKit 临时视频 → 上传自有 OSS → 更新 DB."""
|
||||
|
||||
def _make_persist_job(self, **kwargs):
|
||||
job = MagicMock()
|
||||
job.id = kwargs.get("job_id", "job-1")
|
||||
job.user_id = kwargs.get("user_id", "user-1")
|
||||
job.output_video_url = kwargs.get("output_video_url", "https://temp.mk/output.mp4")
|
||||
job.updated_at = None
|
||||
return job
|
||||
|
||||
def _persist_patches(self, *, job, video_bytes=b"FAKEMP4", download_side_effect=None, upload_url=None):
|
||||
"""统一 patch:SessionLocal、httpx.Client、storage、_sign_media_url."""
|
||||
fake_app_db = ModuleType("app.db")
|
||||
fake_worker_db = ModuleType("worker_app.db")
|
||||
session, factory = _build_session(job)
|
||||
fake_app_db.SessionLocal = factory
|
||||
fake_worker_db.SessionLocal = factory
|
||||
|
||||
# httpx.Client 上下文管理器
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = video_bytes
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_client = MagicMock()
|
||||
fake_client.get.return_value = fake_response
|
||||
fake_client_cm = MagicMock()
|
||||
fake_client_cm.__enter__ = MagicMock(return_value=fake_client)
|
||||
fake_client_cm.__exit__ = MagicMock(return_value=False)
|
||||
FakeHttpxClient = MagicMock(return_value=fake_client_cm)
|
||||
if download_side_effect is not None:
|
||||
fake_client.get.side_effect = download_side_effect
|
||||
|
||||
# storage
|
||||
storage = MagicMock()
|
||||
storage.public_url = "https://oss.example.com/"
|
||||
storage.upload_file.return_value = upload_url or "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
|
||||
# _sign_media_url 内部会调 storage.get_download_url,必须mock返回字符串
|
||||
_upload_url = upload_url or "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
|
||||
storage.get_download_url.return_value = _upload_url + "?signed"
|
||||
|
||||
fake_httpx = ModuleType("httpx")
|
||||
fake_httpx.Client = FakeHttpxClient
|
||||
|
||||
patches = [
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{"app.db": fake_app_db, "worker_app.db": fake_worker_db, "httpx": fake_httpx},
|
||||
),
|
||||
patch("packages.shared.storage.get_shared_storage_service", return_value=storage),
|
||||
patch("app.tasks.lipsync_tts._sign_media_url", side_effect=lambda url: url + "?signed" if url else url),
|
||||
]
|
||||
return session, fake_client, storage, patches
|
||||
|
||||
def test_success_download_upload_updates_db(self):
|
||||
"""正常路径:下载 temp_url → 上传 OSS → 签名 → 写回 DB commit."""
|
||||
from app.tasks.lipsync_tts import persist_output_video_task
|
||||
|
||||
job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4")
|
||||
session, fake_client, storage, patches = self._persist_patches(
|
||||
job=job, video_bytes=b"VIDEODATA", upload_url="https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
|
||||
)
|
||||
entered = [p.__enter__() for p in patches]
|
||||
try:
|
||||
persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4")
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.__exit__(None, None, None)
|
||||
|
||||
fake_client.get.assert_called_once_with("https://temp.mk/x.mp4")
|
||||
storage.upload_file.assert_called_once()
|
||||
call_args = storage.upload_file.call_args.args
|
||||
# 上传的 key 必须是 lipsync-outputs/{user_id}/{job_id}.mp4
|
||||
assert call_args[1] == "lipsync-outputs/user-1/job-1.mp4"
|
||||
# upload_file 返回永久 URL,再被 _sign_media_url 追加 ?signed
|
||||
assert job.output_video_url == "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4?signed"
|
||||
assert job.updated_at is not None
|
||||
session.commit.assert_called_once()
|
||||
session.close.assert_called_once()
|
||||
|
||||
def test_download_failure_keeps_temp_url_no_commit(self):
|
||||
"""下载失败(raise)→ 记录 warning、保留 temp_url、不抛异常."""
|
||||
from app.tasks.lipsync_tts import persist_output_video_task
|
||||
|
||||
job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4")
|
||||
session, fake_client, storage, patches = self._persist_patches(
|
||||
job=job, download_side_effect=RuntimeError("network down")
|
||||
)
|
||||
entered = [p.__enter__() for p in patches]
|
||||
try:
|
||||
persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4")
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.__exit__(None, None, None)
|
||||
|
||||
storage.upload_file.assert_not_called()
|
||||
# output_video_url 保持原值(temp_url)
|
||||
assert job.output_video_url == "https://temp.mk/x.mp4"
|
||||
# 内层 except 不会 commit
|
||||
# 注:若内部发生 commit 说明测试失败
|
||||
session.close.assert_called_once()
|
||||
|
||||
def test_empty_temp_url_skips_persist(self):
|
||||
"""temp_url 为空 → 直接返回,不下载不上传."""
|
||||
from app.tasks.lipsync_tts import persist_output_video_task
|
||||
|
||||
job = self._make_persist_job(output_video_url="")
|
||||
session, fake_client, storage, patches = self._persist_patches(job=job)
|
||||
entered = [p.__enter__() for p in patches]
|
||||
try:
|
||||
persist_output_video_task("job-1", "user-1", "")
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.__exit__(None, None, None)
|
||||
|
||||
fake_client.get.assert_not_called()
|
||||
storage.upload_file.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
session.close.assert_called_once()
|
||||
|
||||
def test_job_not_found_returns_early(self):
|
||||
"""DB 中找不到 job → 直接返回,不抛错."""
|
||||
from app.tasks.lipsync_tts import persist_output_video_task
|
||||
|
||||
session, fake_client, storage, patches = self._persist_patches(job=None)
|
||||
entered = [p.__enter__() for p in patches]
|
||||
try:
|
||||
persist_output_video_task("missing", "user-1", "https://temp.mk/x.mp4")
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.__exit__(None, None, None)
|
||||
|
||||
fake_client.get.assert_not_called()
|
||||
storage.upload_file.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
session.close.assert_called_once()
|
||||
|
||||
|
||||
class TestLipsyncServiceRefreshCompletedAsyncPersist:
|
||||
"""refresh_job_status 在 completed 分支异步转存的单元测试(补 0% 覆盖的 316~335 行)."""
|
||||
|
||||
def test_refresh_completed_dispatches_persist_task(self):
|
||||
"""completed 分支:设置 temp_url → commit → dispatch persist_output_video_task.apply_async."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_job = MagicMock()
|
||||
mock_job.id = "job-1"
|
||||
mock_job.user_id = "user-1"
|
||||
mock_job.mediakit_task_id = "mk-1"
|
||||
mock_job.status = "submitted"
|
||||
mock_job.output_video_url = ""
|
||||
mock_job.output_duration = 0.0
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_task_status.return_value = {
|
||||
"status": "completed",
|
||||
"result": {"video_url": "https://temp.mk/out.mp4", "duration": 25.5},
|
||||
}
|
||||
|
||||
fake_persist_task = MagicMock()
|
||||
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
|
||||
with patch.dict("sys.modules", {}):
|
||||
# 直接 patch 懒 import 路径
|
||||
with patch("app.tasks.lipsync_tts.persist_output_video_task", fake_persist_task, create=False):
|
||||
# 但懒 import 发生在函数内部 from app.tasks.lipsync_tts import persist_output_video_task
|
||||
# 通过 patch sys.modules 的方式提供
|
||||
import sys as _sys
|
||||
|
||||
fake_mod = MagicMock()
|
||||
fake_mod.persist_output_video_task = fake_persist_task
|
||||
_sys.modules["app.tasks.lipsync_tts"] = fake_mod
|
||||
try:
|
||||
result = svc.refresh_job_status("job-1", "user-1")
|
||||
finally:
|
||||
_sys.modules.pop("app.tasks.lipsync_tts", None)
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.output_video_url == "https://temp.mk/out.mp4"
|
||||
assert result.output_duration == 25.5
|
||||
mock_db.commit.assert_called()
|
||||
# 必须在 commit 之后 dispatch
|
||||
fake_persist_task.apply_async.assert_called_once()
|
||||
kwargs = fake_persist_task.apply_async.call_args.kwargs
|
||||
assert kwargs["args"] == ("job-1", "user-1", "https://temp.mk/out.mp4")
|
||||
|
||||
def test_refresh_completed_dispatch_exception_does_not_break_return(self):
|
||||
"""apply_async 抛异常(如 Celery 不可用)→ 捕获 warning,仍返回 completed job."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_job = MagicMock()
|
||||
mock_job.id = "job-2"
|
||||
mock_job.user_id = "user-1"
|
||||
mock_job.mediakit_task_id = "mk-2"
|
||||
mock_job.status = "submitted"
|
||||
mock_job.output_video_url = ""
|
||||
mock_job.output_duration = 0.0
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_job
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_task_status.return_value = {
|
||||
"status": "completed",
|
||||
"result": {"video_url": "https://temp.mk/out2.mp4", "duration": 10.0},
|
||||
}
|
||||
|
||||
fake_persist_task = MagicMock()
|
||||
fake_persist_task.apply_async.side_effect = ConnectionError("celery down")
|
||||
|
||||
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
|
||||
import sys as _sys
|
||||
|
||||
fake_mod = MagicMock()
|
||||
fake_mod.persist_output_video_task = fake_persist_task
|
||||
_sys.modules["app.tasks.lipsync_tts"] = fake_mod
|
||||
try:
|
||||
result = svc.refresh_job_status("job-2", "user-1")
|
||||
finally:
|
||||
_sys.modules.pop("app.tasks.lipsync_tts", None)
|
||||
|
||||
# 即便 dispatch 失败,主流程不受影响:仍然返回 completed + temp_url
|
||||
assert result.status == "completed"
|
||||
assert result.output_video_url == "https://temp.mk/out2.mp4"
|
||||
fake_persist_task.apply_async.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests for sentence timing functions in lipsync_tts."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.api.app.tasks.lipsync_tts import (
|
||||
_compute_sentence_timings,
|
||||
_estimate_sentence_timings_by_chars,
|
||||
_split_script_into_sentences,
|
||||
)
|
||||
|
||||
|
||||
class TestSplitScriptIntoSentences(unittest.TestCase):
|
||||
"""Tests for _split_script_into_sentences."""
|
||||
|
||||
def test_empty_string(self):
|
||||
self.assertEqual(_split_script_into_sentences(""), [])
|
||||
|
||||
def test_none(self):
|
||||
self.assertEqual(_split_script_into_sentences(None), [])
|
||||
|
||||
def test_whitespace_only(self):
|
||||
self.assertEqual(_split_script_into_sentences(" \n "), [])
|
||||
|
||||
def test_single_sentence(self):
|
||||
self.assertEqual(_split_script_into_sentences("你好世界。"), ["你好世界"])
|
||||
|
||||
def test_multiple_sentences_chinese(self):
|
||||
result = _split_script_into_sentences("第一句。第二句!第三句?")
|
||||
self.assertEqual(result, ["第一句", "第二句", "第三句"])
|
||||
|
||||
def test_english_punctuation(self):
|
||||
result = _split_script_into_sentences("Hello World! How are you?")
|
||||
self.assertEqual(result, ["Hello World", "How are you"])
|
||||
|
||||
def test_semicolons(self):
|
||||
result = _split_script_into_sentences("第一部分;第二部分;第三部分")
|
||||
self.assertEqual(result, ["第一部分", "第二部分", "第三部分"])
|
||||
|
||||
def test_newlines(self):
|
||||
result = _split_script_into_sentences("第一行\n第二行\n第三行")
|
||||
self.assertEqual(result, ["第一行", "第二行", "第三行"])
|
||||
|
||||
def test_no_trailing_punctuation(self):
|
||||
result = _split_script_into_sentences("没有标点的句子")
|
||||
self.assertEqual(result, ["没有标点的句子"])
|
||||
|
||||
|
||||
class TestEstimateSentenceTimingsByChars(unittest.TestCase):
|
||||
"""Tests for _estimate_sentence_timings_by_chars."""
|
||||
|
||||
def test_empty_sentences(self):
|
||||
self.assertEqual(_estimate_sentence_timings_by_chars([], 10.0), [])
|
||||
|
||||
def test_zero_duration(self):
|
||||
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], 0), [])
|
||||
|
||||
def test_negative_duration(self):
|
||||
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], -5.0), [])
|
||||
|
||||
def test_single_sentence(self):
|
||||
result = _estimate_sentence_timings_by_chars(["hello"], 10.0)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
||||
self.assertAlmostEqual(result[0]["end_time"], 10.0)
|
||||
|
||||
def test_two_equal_sentences(self):
|
||||
result = _estimate_sentence_timings_by_chars(["你好", "世界"], 10.0)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
||||
self.assertAlmostEqual(result[0]["end_time"], 5.0)
|
||||
self.assertAlmostEqual(result[1]["start_time"], 5.0)
|
||||
self.assertAlmostEqual(result[1]["end_time"], 10.0)
|
||||
|
||||
def test_unequal_char_distribution(self):
|
||||
result = _estimate_sentence_timings_by_chars(["ABCD", "EF"], 9.0)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
||||
self.assertAlmostEqual(result[0]["end_time"], 6.0) # 4/6 * 9 = 6
|
||||
self.assertAlmostEqual(result[1]["start_time"], 6.0)
|
||||
self.assertAlmostEqual(result[1]["end_time"], 9.0)
|
||||
|
||||
def test_timing_structure(self):
|
||||
result = _estimate_sentence_timings_by_chars(["句子一", "句子二"], 6.0)
|
||||
for item in result:
|
||||
self.assertIn("index", item)
|
||||
self.assertIn("text", item)
|
||||
self.assertIn("start_time", item)
|
||||
self.assertIn("end_time", item)
|
||||
|
||||
|
||||
class TestComputeSentenceTimings(unittest.TestCase):
|
||||
"""Tests for _compute_sentence_timings."""
|
||||
|
||||
def test_empty_script_returns_empty(self):
|
||||
self.assertEqual(_compute_sentence_timings(b"fake_audio", "", 10.0), [])
|
||||
|
||||
def test_none_script_returns_empty(self):
|
||||
self.assertEqual(_compute_sentence_timings(b"fake_audio", None, 10.0), [])
|
||||
|
||||
@patch("os.unlink")
|
||||
@patch.object(tempfile, "NamedTemporaryFile")
|
||||
@patch.object(subprocess, "run")
|
||||
def test_silence_detection_insufficient_fallback(self, mock_run, mock_tmpfile, mock_unlink):
|
||||
"""When silence detection finds too few points, fallback to char estimation."""
|
||||
mock_run.return_value = MagicMock(stderr="", returncode=0)
|
||||
mock_tmp = MagicMock()
|
||||
mock_tmp.name = "/tmp/fake.mp3"
|
||||
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
|
||||
mock_tmp.__exit__ = MagicMock(return_value=False)
|
||||
mock_tmpfile.return_value = mock_tmp
|
||||
|
||||
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
|
||||
|
||||
# Should fallback to char estimation with 3 sentences
|
||||
self.assertEqual(len(result), 3)
|
||||
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
||||
|
||||
@patch("os.unlink")
|
||||
@patch.object(tempfile, "NamedTemporaryFile")
|
||||
@patch.object(subprocess, "run")
|
||||
def test_silence_detection_with_enough_points(self, mock_run, mock_tmpfile, mock_unlink):
|
||||
"""When silence detection finds enough points, use them for boundaries."""
|
||||
mock_run.return_value = MagicMock(
|
||||
stderr="[silencedetect] silence_end: 3.5 | silence_duration: 0.4\n"
|
||||
"[silencedetect] silence_end: 7.0 | silence_duration: 0.3\n",
|
||||
returncode=0,
|
||||
)
|
||||
mock_tmp = MagicMock()
|
||||
mock_tmp.name = "/tmp/fake.mp3"
|
||||
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
|
||||
mock_tmp.__exit__ = MagicMock(return_value=False)
|
||||
mock_tmpfile.return_value = mock_tmp
|
||||
|
||||
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
|
||||
|
||||
self.assertEqual(len(result), 3)
|
||||
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
||||
self.assertAlmostEqual(result[0]["end_time"], 3.5)
|
||||
self.assertAlmostEqual(result[1]["start_time"], 3.5)
|
||||
self.assertAlmostEqual(result[1]["end_time"], 7.0)
|
||||
self.assertAlmostEqual(result[2]["start_time"], 7.0)
|
||||
self.assertAlmostEqual(result[2]["end_time"], 10.0)
|
||||
|
||||
@patch("os.unlink")
|
||||
@patch.object(tempfile, "NamedTemporaryFile")
|
||||
@patch.object(subprocess, "run")
|
||||
def test_ffmpeg_exception_fallback(self, mock_run, mock_tmpfile, mock_unlink):
|
||||
"""When ffmpeg raises an exception, fallback to char estimation."""
|
||||
mock_run.side_effect = Exception("ffmpeg not found")
|
||||
mock_tmp = MagicMock()
|
||||
mock_tmp.name = "/tmp/fake.mp3"
|
||||
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
|
||||
mock_tmp.__exit__ = MagicMock(return_value=False)
|
||||
mock_tmpfile.return_value = mock_tmp
|
||||
|
||||
result = _compute_sentence_timings(b"fake_audio", "句子一。句子二。", 6.0)
|
||||
|
||||
# Should fallback to char estimation
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
||||
self.assertAlmostEqual(result[0]["end_time"], 3.0)
|
||||
self.assertAlmostEqual(result[1]["start_time"], 3.0)
|
||||
self.assertAlmostEqual(result[1]["end_time"], 6.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from dataclasses import FrozenInstanceError
|
||||
from unittest.mock import patch
|
||||
@@ -29,10 +30,12 @@ from packages.domain.video_filter_builder import (
|
||||
ClipFilterChain,
|
||||
_escape_drawtext_text,
|
||||
_resolve_font_path,
|
||||
build_broll_overlay_filter,
|
||||
build_clip_filter,
|
||||
build_concat_filter,
|
||||
build_filter_complex,
|
||||
build_title_drawtext_filter,
|
||||
build_title_overlay_filter,
|
||||
build_xfade_filter,
|
||||
chain_filters,
|
||||
has_audio,
|
||||
@@ -1039,6 +1042,29 @@ class TestDrawtextBoldFalse(unittest.TestCase):
|
||||
# 粗体应通过 borderw 实现
|
||||
self.assertIn("borderw=", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_bold_default_uses_black_stroke_when_no_bold_font(self, mock_font):
|
||||
"""默认 bold=true 且无 Bold 字体文件时,使用黑色细描边(borderw=2 + 黑),
|
||||
不得使用与文字同色的 borderw>=3(否则会造成竖屏小字号重影)。"""
|
||||
mock_font.return_value = "" # 无粗体字体
|
||||
result = build_title_drawtext_filter({"text": "标题"})
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("borderw=2", result)
|
||||
# 黑描边:要么是 black 关键字,要么是 000000
|
||||
self.assertTrue("bordercolor=black" in result or "bordercolor=000000" in result)
|
||||
self.assertNotIn("borderw=3", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_bold_with_user_stroke_preserves_user_color(self, mock_font):
|
||||
"""用户显式开启 stroke 时,stroke 颜色/宽度优先于默认粗体黑边。"""
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter(
|
||||
{"text": "标题", "bold": True, "stroke": {"width": 4, "color": "#ffffff"}}
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("borderw=4", result)
|
||||
self.assertIn("bordercolor=ffffff", result) # 去掉 # 前缀
|
||||
|
||||
|
||||
class TestDrawtextPositionBranches(unittest.TestCase):
|
||||
"""位置相关分支覆盖。"""
|
||||
@@ -1065,12 +1091,23 @@ class TestDrawtextPositionBranches(unittest.TestCase):
|
||||
self.assertIn("y=h-text_h-50", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_position_custom_with_float_coords(self, mock_font):
|
||||
def test_position_custom_with_percentage_coords(self, mock_font):
|
||||
"""自定义位置:百分比坐标转换为 drawtext 表达式."""
|
||||
mock_font.return_value = ""
|
||||
# pos_x=50, pos_y=30 → x=(w-text_w)*0.5000, y=(h-text_h)*0.3000
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 50, "pos_y": 30})
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("x=(w-text_w)*0.5000", result)
|
||||
self.assertIn("y=(h-text_h)*0.3000", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_position_custom_clamped_to_100(self, mock_font):
|
||||
"""自定义位置:超过100的坐标被截断到100%."""
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3})
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("x=100", result)
|
||||
self.assertIn("y=200", result)
|
||||
self.assertIn("x=(w-text_w)*1.0000", result)
|
||||
self.assertIn("y=(h-text_h)*1.0000", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_position_custom_bool_coords_fallback(self, mock_font):
|
||||
@@ -1127,5 +1164,97 @@ class TestDrawtextNotDictConfig(unittest.TestCase):
|
||||
self.assertIsNone(build_title_drawtext_filter([1, 2, 3]))
|
||||
|
||||
|
||||
class TestTitleOverlay(unittest.TestCase):
|
||||
"""build_title_overlay_filter 单元测试(WYSIWYG PNG 叠加路径)。"""
|
||||
|
||||
def test_overlay_filter_format(self):
|
||||
"""PNG 文件存在时返回正确的 overlay 滤镜字符串。"""
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp.write(b"\x89PNG\r\n\x1a\n")
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
result = build_title_overlay_filter(
|
||||
{"text": "标题"},
|
||||
output_width=720,
|
||||
output_height=1280,
|
||||
title_png_path=tmp_path,
|
||||
title_input_label="[2:v]",
|
||||
base_label="[vout]",
|
||||
output_label="vout_titled",
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("[vout][2:v]overlay=0:0[vout_titled]", result)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_overlay_default_labels(self):
|
||||
"""不传 label 参数时使用默认 [0:v] / [1:v] / vout_titled。"""
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp.write(b"\x89PNG\r\n\x1a\n")
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
result = build_title_overlay_filter(
|
||||
{"text": "标题"},
|
||||
output_width=720,
|
||||
output_height=1280,
|
||||
title_png_path=tmp_path,
|
||||
)
|
||||
self.assertEqual(result, "[0:v][1:v]overlay=0:0[vout_titled]")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_overlay_returns_none_when_png_missing(self):
|
||||
"""PNG 文件不存在时返回 None,供调用方降级到 drawtext。"""
|
||||
result = build_title_overlay_filter(
|
||||
{"text": "标题"},
|
||||
output_width=720,
|
||||
output_height=1280,
|
||||
title_png_path="/nonexistent/path/title.png",
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_overlay_returns_none_for_empty_config(self):
|
||||
"""title_config 为空/非 dict 时返回 None。"""
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp.write(b"\x89PNG\r\n\x1a\n")
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
self.assertIsNone(
|
||||
build_title_overlay_filter(
|
||||
None,
|
||||
output_width=720,
|
||||
output_height=1280,
|
||||
title_png_path=tmp_path,
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
build_title_overlay_filter(
|
||||
"not a dict",
|
||||
output_width=720,
|
||||
output_height=1280,
|
||||
title_png_path=tmp_path,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_overlay_returns_none_for_empty_path(self):
|
||||
"""title_png_path 为空字符串时返回 None。"""
|
||||
self.assertIsNone(
|
||||
build_title_overlay_filter(
|
||||
{"text": "标题"},
|
||||
output_width=720,
|
||||
output_height=1280,
|
||||
title_png_path="",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user