perf(ai-avatar): 对口型加速+封面流程重构 #1872
@@ -191,7 +191,6 @@ def retry_render_job(
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
|
||||
|
||||
|
||||
# ── POST /smart-cover — 智能获取封面(MediaKit 抽帧 + 评分选帧)────────
|
||||
|
||||
|
||||
@@ -220,7 +219,9 @@ def generate_avatar_smart_cover(
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"智能封面生成异常: user=%s video_url=%s err=%s",
|
||||
current_user.user.id, video_url[:80], exc,
|
||||
current_user.user.id,
|
||||
video_url[:80],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
cover_url = ""
|
||||
@@ -233,3 +234,72 @@ def generate_avatar_smart_cover(
|
||||
)
|
||||
logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120])
|
||||
return SmartCoverResponse(cover_url=cover_url, status="completed")
|
||||
|
||||
|
||||
# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)────────
|
||||
|
||||
|
||||
@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse)
|
||||
def generate_render_smart_cover(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS).
|
||||
|
||||
- 必须等渲染任务 completed 后才可调用(否则返回 400)
|
||||
- 生成成功后自动更新 render_job 的 cover_config 与 output_cover_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:
|
||||
# 成片已叠加标题,不传 title_config 避免双重叠加
|
||||
cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"渲染成片智能封面生成异常: 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 = ""
|
||||
|
||||
if not cover_url:
|
||||
return SmartCoverResponse(
|
||||
cover_url="",
|
||||
status="fallback_failed",
|
||||
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
|
||||
)
|
||||
|
||||
# 更新 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")
|
||||
|
||||
@@ -197,9 +197,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:
|
||||
@@ -311,64 +310,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
|
||||
@@ -535,41 +493,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.
|
||||
|
||||
|
||||
@@ -313,11 +313,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"
|
||||
|
||||
@@ -301,7 +301,9 @@ 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,
|
||||
@@ -316,28 +318,28 @@ def tts_synthesize_and_submit(
|
||||
},
|
||||
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,避免重新从 OSS 下载(私有桶未签名会失败)
|
||||
# 直接复用步骤 2 已下载到内存的 audio_data,避免重新下载
|
||||
import os as _os
|
||||
|
||||
_st_tmp_path = None
|
||||
try:
|
||||
import subprocess as _sp
|
||||
import tempfile as _tmpf
|
||||
@@ -448,3 +450,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,9 +23,10 @@ import {
|
||||
getLipsyncJob,
|
||||
submitRender,
|
||||
getRenderJob,
|
||||
generateSmartCover,
|
||||
generateRenderSmartCover,
|
||||
} from "./api/aiAvatar"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { RenderJob } from "./types"
|
||||
import {
|
||||
normalizeEmotion,
|
||||
buildTitleConfigPayload,
|
||||
@@ -54,8 +55,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 +62,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)
|
||||
@@ -223,7 +224,12 @@ const AiAvatarPage: React.FC = () => {
|
||||
pip_scale: seg.pip_scale,
|
||||
})) as never,
|
||||
title_config: buildTitleConfigPayload(state.titleConfig),
|
||||
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
|
||||
// 封面不阻塞渲染:用户未选定封面时传空 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,17 +237,20 @@ 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,
|
||||
@@ -282,38 +291,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,
|
||||
@@ -321,7 +340,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 (
|
||||
@@ -457,9 +476,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}
|
||||
|
||||
@@ -94,3 +94,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
|
||||
}
|
||||
|
||||
@@ -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 || 48) * 0.35}px`, // 预览容器缩放,与 PanelLipsyncPreview 对齐
|
||||
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 {
|
||||
// 无描边无阴影时,不加额外效果(与后端 drawtext 对齐:无 stroke/shadow 则不加)
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -67,9 +67,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -387,3 +387,242 @@ 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"
|
||||
|
||||
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/u1/j1.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()
|
||||
# 上传的 key 必须是 lipsync-outputs/{user_id}/{job_id}.mp4
|
||||
key_arg = (
|
||||
storage.upload_file.call_args.args[1]
|
||||
if storage.upload_file.call_args.args
|
||||
else storage.upload_file.call_args.kwargs.get("key")
|
||||
)
|
||||
# upload_file(data, key, content_type=...)
|
||||
call_args = storage.upload_file.call_args.args
|
||||
assert call_args[1] == "lipsync-outputs/user-1/job-1.mp4"
|
||||
# output_video_url 被替换为签名后的永久 URL
|
||||
assert job.output_video_url == "https://oss.example.com/lipsync-outputs/u1/j1.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()
|
||||
|
||||
Reference in New Issue
Block a user