fix(ai-avatar): 智能封面抽帧超时修复 + 封面流程改造(选封面后点完成才入库) #1877
@@ -18,6 +18,7 @@ from app.dependencies import get_db_session
|
||||
from app.schemas.ai_avatar_render import (
|
||||
AiAvatarRenderJobResponse,
|
||||
CreateAiAvatarRenderRequest,
|
||||
FinalizeRenderResponse,
|
||||
SmartCoverResponse,
|
||||
)
|
||||
from app.services.ai_avatar_cover_service import generate_smart_cover
|
||||
@@ -257,3 +258,67 @@ def generate_render_smart_cover(
|
||||
cover_url[:120],
|
||||
)
|
||||
return SmartCoverResponse(cover_url=cover_url, status="completed")
|
||||
|
||||
# ── POST /{job_id}/finalize — 封面选定后正式入库成片库 ────────────────────
|
||||
|
||||
|
||||
@router.post("/{job_id}/finalize", response_model=FinalizeRenderResponse)
|
||||
def finalize_render_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""用户完成封面选择后,将视频正式保存到成片库.
|
||||
|
||||
- 必须等渲染任务 completed 后才可调用
|
||||
- 如果已通过 smart-cover/custom-cover 设置了封面,会自动带上
|
||||
- 返回成片库视频ID
|
||||
- 幂等:已 finalize 的任务重复调用会返回 existing 记录
|
||||
"""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, 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="请先完成视频生成")
|
||||
|
||||
# 幂等检查(通过 generation_task_id=job_id 识别,finalize_job 内部也做了一次,这里提前返回简化)
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
existing = (
|
||||
db.query(GeneratedVideoModel)
|
||||
.filter(
|
||||
GeneratedVideoModel.user_id == current_user.user.id,
|
||||
GeneratedVideoModel.generation_task_id == job_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
return FinalizeRenderResponse(
|
||||
video_id=existing.id,
|
||||
cover_url=existing.thumbnail_url or "",
|
||||
status="already_finalized",
|
||||
)
|
||||
|
||||
try:
|
||||
video = svc.finalize_job(job_id, current_user.user.id)
|
||||
return FinalizeRenderResponse(
|
||||
video_id=video.id,
|
||||
cover_url=video.thumbnail_url or job.output_cover_url or "",
|
||||
status="success",
|
||||
)
|
||||
except AiAvatarRenderError as exc:
|
||||
status_map = {
|
||||
"RenderJobNotFound": 404,
|
||||
"RenderNotCompleted": 400,
|
||||
"OutputVideoMissing": 400,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status_map.get(exc.code, 400),
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("渲染任务finalize失败: job_id=%s err=%s", job_id, exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"保存到成片库失败: {str(exc)}")
|
||||
|
||||
@@ -116,3 +116,11 @@ class SmartCoverResponse(BaseModel):
|
||||
cover_url: str = Field("", description="封面图公网 URL(OSS,非临时);失败为空")
|
||||
status: str = Field("completed", description="completed / fallback_failed")
|
||||
message: str = Field("", description="失败原因(如有)")
|
||||
|
||||
|
||||
class FinalizeRenderResponse(BaseModel):
|
||||
"""封面选好后点「完成」,正式入库成片库的响应."""
|
||||
|
||||
video_id: str = Field(..., description="成片库视频ID")
|
||||
cover_url: str = Field("", description="封面URL")
|
||||
status: str = Field("success", description="success/already_finalized")
|
||||
|
||||
@@ -22,9 +22,9 @@ from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MediaKit 抽帧轮询参数:poll_interval=1s × max_poll=15 → 最长 15s,配合前端 120s 超时足够
|
||||
COVER_POLL_INTERVAL = 1.0
|
||||
COVER_MAX_POLL_ATTEMPTS = 15
|
||||
# MediaKit 抽帧轮询参数:poll_interval=2s × max_poll=30 → 最长 60s(与 mediakit_client 默认值/lipsync 轮询保持一致,防止合成视频下载+抽帧超时)
|
||||
COVER_POLL_INTERVAL = 2.0
|
||||
COVER_MAX_POLL_ATTEMPTS = 30
|
||||
|
||||
# 帧图片下载超时(秒)
|
||||
FRAME_DOWNLOAD_TIMEOUT = 20
|
||||
|
||||
@@ -399,41 +399,9 @@ class AiAvatarRenderService:
|
||||
self.db.commit()
|
||||
logger.info("渲染任务完成: %s", job_id)
|
||||
|
||||
# 7. 自动保存成片记录到成片库
|
||||
if job.output_video_url:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
clip_name = f"AI数字人_{job_id[:8]}"
|
||||
# AI数字人入口是独立页面,前端可能不传 project_id(无项目概念),
|
||||
# 兜底为 "ai_avatar" 避免 DB 非空约束/查询问题;generation_task_id 同样兜底用 render_job_id
|
||||
clip_project_id = (job.project_id or "").strip() or "ai_avatar"
|
||||
clip_generation_task_id = (job.lipsync_job_id or "").strip() or job_id
|
||||
clip = GeneratedVideo.create(
|
||||
project_id=clip_project_id,
|
||||
generation_task_id=clip_generation_task_id,
|
||||
name=clip_name,
|
||||
file_url=job.output_video_url,
|
||||
user_id=job.user_id,
|
||||
duration=job.output_duration or 0.0,
|
||||
thumbnail_url=job.output_cover_url or None,
|
||||
generation_params={
|
||||
"source": "ai_avatar_render",
|
||||
"render_job_id": job.id,
|
||||
},
|
||||
)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
|
||||
video_repo.create(clip)
|
||||
logger.info("成片记录已保存到成片库: clip_id=%s, render_job=%s", clip.id, job_id)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"自动保存成片记录失败(不影响渲染任务状态): render_job=%s",
|
||||
job_id,
|
||||
exc_info=True,
|
||||
)
|
||||
# 7. 渲染完成,停留在「待选封面」状态:不自动入库。
|
||||
# 用户在前端选好封面、点「完成」后,由 /{job_id}/finalize 接口显式入库。
|
||||
logger.info("渲染任务完成,等待用户选择封面后入库: job_id=%s", job_id)
|
||||
|
||||
except AiAvatarRenderError as exc:
|
||||
job.status = "failed"
|
||||
@@ -450,6 +418,87 @@ class AiAvatarRenderService:
|
||||
logger.exception("渲染任务异常 [%s]", job_id)
|
||||
raise
|
||||
|
||||
def _persist_to_library(self, job: AiAvatarRenderJob, cover_url: Optional[str] = None):
|
||||
"""将渲染结果写入成片库,返回 GeneratedVideo 领域对象.
|
||||
|
||||
Args:
|
||||
job: 渲染任务(必须 status=completed 且 output_video_url 非空)
|
||||
cover_url: 可选的封面 URL 覆盖(finalize 时传入即优先使用,否则取 job.output_cover_url)
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
clip_name = f"AI数字人_{job.id[:8]}"
|
||||
# AI数字人入口是独立页面,前端可能不传 project_id(无项目概念),
|
||||
# 兜底为 "ai_avatar" 避免 DB 非空约束/查询问题;generation_task_id 用 render_job_id 便于反查。
|
||||
clip_project_id = (job.project_id or "").strip() or "ai_avatar"
|
||||
clip_generation_task_id = job.id
|
||||
effective_cover = (cover_url or "").strip() if cover_url else (job.output_cover_url or "").strip()
|
||||
clip = GeneratedVideo.create(
|
||||
project_id=clip_project_id,
|
||||
generation_task_id=clip_generation_task_id,
|
||||
name=clip_name,
|
||||
file_url=job.output_video_url,
|
||||
user_id=job.user_id,
|
||||
duration=job.output_duration or 0.0,
|
||||
thumbnail_url=effective_cover or None,
|
||||
generation_params={
|
||||
"source": "ai_avatar_render",
|
||||
"render_job_id": job.id,
|
||||
},
|
||||
)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
|
||||
video_repo.create(clip)
|
||||
logger.info("[数字人渲染] 成片已入库: clip_id=%s render_job=%s", clip.id, job.id)
|
||||
return clip
|
||||
|
||||
def finalize_job(self, job_id: str, user_id: str, cover_url: Optional[str] = None):
|
||||
"""用户在前端点「完成」后调用:将已 completed 的渲染任务正式入库到成片库.
|
||||
|
||||
- 必须 status=completed 才可调用
|
||||
- cover_url 若传入则优先使用并回写 job.output_cover_url;否则使用 job.output_cover_url(smart-cover/custom-cover 已写入)
|
||||
- 幂等:已入库则返回已存在的 GeneratedVideo
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
job = self.get_render_job(job_id, user_id)
|
||||
if job is None:
|
||||
raise AiAvatarRenderError("渲染任务不存在", code="RenderJobNotFound")
|
||||
if job.status != "completed":
|
||||
raise AiAvatarRenderError(f"渲染任务未完成(当前状态: {job.status}),无法入库", code="RenderNotCompleted")
|
||||
if not (job.output_video_url or "").strip():
|
||||
raise AiAvatarRenderError("渲染成片视频 URL 为空,无法入库", code="OutputVideoMissing")
|
||||
|
||||
# 幂等检查:已入库直接返回现有记录(通过 generation_task_id=job_id 识别,
|
||||
# 因为入库时 generation_task_id 被设置为 render_job_id 自身)
|
||||
existing = (
|
||||
self.db.query(GeneratedVideoModel)
|
||||
.filter(
|
||||
GeneratedVideoModel.user_id == user_id,
|
||||
GeneratedVideoModel.generation_task_id == job_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info("[数字人渲染] finalize 幂等命中,返回已存在记录: clip_id=%s job_id=%s", existing.id, job_id)
|
||||
return SQLAlchemyGeneratedVideoRepository(self.db).get(existing.id)
|
||||
|
||||
# 传入 cover_url 时回写到 job
|
||||
if cover_url and cover_url.strip():
|
||||
job.output_cover_url = cover_url.strip()
|
||||
# 同步更新 cover_config,保持 smart-cover 路径一致
|
||||
if isinstance(job.cover_config, dict):
|
||||
job.cover_config = {**job.cover_config, "mode": "auto_frame", "url": cover_url.strip()}
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
|
||||
return self._persist_to_library(job, cover_url=cover_url)
|
||||
|
||||
def _download_video(self, url: str) -> str:
|
||||
"""下载视频到临时文件."""
|
||||
import httpx
|
||||
|
||||
@@ -1265,3 +1265,20 @@
|
||||
width: auto;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
/* 渲染完成后的封面确认区 */
|
||||
.aa-finalize-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 0 8px;
|
||||
}
|
||||
|
||||
.aa-finalize-cover {
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
aspect-ratio: 9/16;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import PanelTitleConfig from "./components/PanelTitleConfig"
|
||||
import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate"
|
||||
import { ModalAssetPicker } from "./components/ModalAssetPicker"
|
||||
import ModalBRollEditor from "./components/ModalBRollEditor"
|
||||
import ModalCoverSelect from "./components/ModalCoverSelect"
|
||||
import {
|
||||
getScripts,
|
||||
getAssetById,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
submitRender,
|
||||
getRenderJob,
|
||||
generateRenderSmartCover,
|
||||
finalizeRenderJob,
|
||||
} from "./api/aiAvatar"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { RenderJob, SentenceTiming } from "./types"
|
||||
@@ -72,6 +74,10 @@ const AiAvatarPage: React.FC = () => {
|
||||
const [renderErrorMessage, setRenderErrorMessage] = useState("")
|
||||
/* ── 当前渲染任务对象 ── */
|
||||
const [currentRenderJob, setCurrentRenderJob] = useState<RenderJob | null>(null)
|
||||
/* ── 封面选择弹窗 ── */
|
||||
const [showCoverModal, setShowCoverModal] = useState(false)
|
||||
const [selectedCoverUrl, setSelectedCoverUrl] = useState("")
|
||||
const [finalizeLoading, setFinalizeLoading] = useState(false)
|
||||
|
||||
/* ── 对口型轮询 ── */
|
||||
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
@@ -387,6 +393,9 @@ const AiAvatarPage: React.FC = () => {
|
||||
setRenderProgress(job.progress ?? 0)
|
||||
setRenderErrorMessage("")
|
||||
setCurrentRenderJob(job as RenderJob)
|
||||
// 每次新渲染重置封面状态
|
||||
setSelectedCoverUrl("")
|
||||
setShowCoverModal(false)
|
||||
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = setInterval(async () => {
|
||||
@@ -398,6 +407,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = null
|
||||
setRenderStatus("completed")
|
||||
// 不再自动入库/自动跳转:渲染完成后停留在主页面,等用户选封面、点「完成」才入库
|
||||
// 如果后端在透传时已经带了封面(旧逻辑兜底),同步本地状态
|
||||
if (updated.output_cover_url) {
|
||||
state.setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
@@ -405,8 +416,9 @@ const AiAvatarPage: React.FC = () => {
|
||||
smart_cover_url: updated.output_cover_url,
|
||||
thumbnail_url: updated.output_cover_url,
|
||||
}))
|
||||
setSelectedCoverUrl(updated.output_cover_url)
|
||||
}
|
||||
message.success("视频已生成并保存到成片库")
|
||||
message.success("视频生成完成,请选择封面")
|
||||
} else if (updated.status === "failed") {
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = null
|
||||
@@ -467,6 +479,68 @@ const AiAvatarPage: React.FC = () => {
|
||||
[],
|
||||
)
|
||||
|
||||
/* ── 封面弹窗回调 ── */
|
||||
const handleCoverSelected = useCallback((coverUrl: string) => {
|
||||
setSelectedCoverUrl(coverUrl || "")
|
||||
}, [])
|
||||
|
||||
const handleOpenCoverModal = useCallback(() => {
|
||||
if (currentRenderJob?.status !== "completed") {
|
||||
message.warning("请先完成视频生成")
|
||||
return
|
||||
}
|
||||
setShowCoverModal(true)
|
||||
}, [currentRenderJob])
|
||||
|
||||
const handleCloseCoverModal = useCallback(() => {
|
||||
setShowCoverModal(false)
|
||||
}, [])
|
||||
|
||||
/* ── 自定义上传封面(本地预览,不单独上传;点完成时一起入库) ── */
|
||||
const handleUploadCover = useCallback(
|
||||
(file: File) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
state.setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
mode: "upload",
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
}))
|
||||
setSelectedCoverUrl(url)
|
||||
},
|
||||
[state],
|
||||
)
|
||||
|
||||
/* ── 点「完成」:调用 finalize 入库成片库,成功后跳转到成片库 ── */
|
||||
const handleFinalize = useCallback(async () => {
|
||||
if (!currentRenderJob?.id) {
|
||||
message.error("渲染任务不存在")
|
||||
return
|
||||
}
|
||||
if (currentRenderJob.status !== "completed") {
|
||||
message.warning("请先完成视频生成")
|
||||
return
|
||||
}
|
||||
setFinalizeLoading(true)
|
||||
try {
|
||||
const res = await finalizeRenderJob(currentRenderJob.id)
|
||||
if (res.data?.status === "success" || res.data?.status === "already_finalized") {
|
||||
message.success("已保存到成片库")
|
||||
navigate("/app/products")
|
||||
} else {
|
||||
message.error("保存失败,请重试")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("finalize 失败:", err)
|
||||
const errMsg =
|
||||
(err as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail ||
|
||||
(err instanceof Error ? err.message : "保存到成片库失败")
|
||||
message.error(typeof errMsg === "string" ? errMsg : "保存到成片库失败")
|
||||
} finally {
|
||||
setFinalizeLoading(false)
|
||||
}
|
||||
}, [currentRenderJob, navigate])
|
||||
|
||||
/* ── 配置汇总 ── */
|
||||
const coverStatus: "not_ready" | "pending" | "selected" = (() => {
|
||||
if (
|
||||
@@ -623,26 +697,96 @@ const AiAvatarPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板5:封面 & 生成 */}
|
||||
{/* 面板5:封面 & 生成
|
||||
- 渲染未完成:显示分辨率/配置摘要/「开始生成」按钮(PanelCoverAndGenerate setup 变体,无封面区)
|
||||
- 渲染完成:显示封面预览 + 「🎬 选择封面」/「✅ 完成」按钮,封面选择在弹窗中完成 */}
|
||||
<div className={`aa-panel aa-panel--s2${collapsed.cover ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
|
||||
<span className="aa-panel__title">封面 & 生成</span>
|
||||
<span className="aa-panel__title">
|
||||
{currentRenderJob?.status === "completed" ? "视频已生成" : "封面 & 生成"}
|
||||
</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelCoverAndGenerate
|
||||
coverConfig={state.coverConfig}
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
renderJob={currentRenderJob}
|
||||
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
summary={summary}
|
||||
/>
|
||||
{currentRenderJob?.status !== "completed" ? (
|
||||
<PanelCoverAndGenerate
|
||||
variant="setup"
|
||||
coverConfig={state.coverConfig}
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
renderJob={currentRenderJob}
|
||||
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
summary={summary}
|
||||
/>
|
||||
) : (
|
||||
<div className="aa-finalize-section">
|
||||
<div style={{ marginBottom: 8, fontSize: 13, color: "#1a1a2e", fontWeight: 500 }}>
|
||||
✅ 视频生成完成,请选择封面后点「完成」入库
|
||||
</div>
|
||||
<div className="aa-finalize-cover">
|
||||
{selectedCoverUrl ? (
|
||||
<img
|
||||
src={selectedCoverUrl}
|
||||
alt="封面预览"
|
||||
draggable={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "2px dashed #d9d9d9",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#8c8ca1",
|
||||
fontSize: 12,
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 24 }}>🎬</span>
|
||||
<span>尚未选择封面</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--ghost"
|
||||
onClick={handleOpenCoverModal}
|
||||
>
|
||||
🎬 选择封面
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
onClick={handleFinalize}
|
||||
disabled={finalizeLoading}
|
||||
>
|
||||
{finalizeLoading ? "⏳ 保存中..." : "✅ 完成"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -938,17 +1082,19 @@ const AiAvatarPage: React.FC = () => {
|
||||
<>
|
||||
<div style={{ fontSize: 48 }}>✅</div>
|
||||
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
|
||||
视频已保存到成片库
|
||||
视频生成完成,请选择封面
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1", textAlign: "center" }}>
|
||||
关闭此窗口后可在下方选择封面并保存到成片库
|
||||
</div>
|
||||
<button
|
||||
className="aa-btn"
|
||||
className="aa-btn aa-btn--primary"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => {
|
||||
setShowRenderModal(false)
|
||||
navigate("/app/products")
|
||||
}}
|
||||
>
|
||||
📁 查看成片
|
||||
🎬 选择封面
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -979,6 +1125,20 @@ const AiAvatarPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 封面选择弹窗 */}
|
||||
<ModalCoverSelect
|
||||
open={showCoverModal}
|
||||
onClose={handleCloseCoverModal}
|
||||
renderJob={currentRenderJob}
|
||||
coverConfig={state.coverConfig}
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
|
||||
onUploadCover={handleUploadCover}
|
||||
onCoverSelected={handleCoverSelected}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,3 +123,9 @@ export const generateRenderSmartCover = async (
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 封面选定后点「完成」正式入库(POST /ai-avatar/render/{job_id}/finalize) ── */
|
||||
export const finalizeRenderJob = (renderId: string) =>
|
||||
apiClient.post<{ video_id: string; cover_url: string; status: string }>(
|
||||
`/ai-avatar/render/${renderId}/finalize`,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AI数字人 — 封面选择弹窗
|
||||
* 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-cover 变体)提供
|
||||
* 智能抽帧 + 自定义上传 + 预览 + 确定按钮。
|
||||
*/
|
||||
import React from "react"
|
||||
import type { AiAvatarCoverConfig, RenderJob } from "../types"
|
||||
import PanelCoverAndGenerate from "./PanelCoverAndGenerate"
|
||||
|
||||
interface ModalCoverSelectProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
renderJob: RenderJob | null
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
|
||||
onUploadCover?: (file: File) => void
|
||||
onCoverSelected: (coverUrl: string) => void
|
||||
}
|
||||
|
||||
const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
renderJob,
|
||||
coverConfig,
|
||||
onCoverConfigChange,
|
||||
onGenerateRenderSmartCover,
|
||||
onUploadCover,
|
||||
onCoverSelected,
|
||||
}) => {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 480 }}>
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择封面</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="aa-modal__body" style={{ padding: 20 }}>
|
||||
<PanelCoverAndGenerate
|
||||
variant="select-cover"
|
||||
coverConfig={coverConfig}
|
||||
onCoverConfigChange={onCoverConfigChange}
|
||||
renderJob={renderJob}
|
||||
onGenerateRenderSmartCover={onGenerateRenderSmartCover}
|
||||
onUploadCover={onUploadCover}
|
||||
onClose={onClose}
|
||||
onCoverSelected={onCoverSelected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalCoverSelect
|
||||
@@ -1,31 +1,37 @@
|
||||
/**
|
||||
* AI数字人 — 面板5:分辨率/配置摘要/生成按钮/封面
|
||||
* v3 调整(步骤③④):
|
||||
* - 布局顺序:分辨率 → 配置摘要卡片 → 🔘「开始生成视频」按钮 → (渲染完成后)封面区域
|
||||
* - 渲染未完成时封面区域显示占位态,按钮 disabled
|
||||
* - 「智能获取封面」从最终成片抽帧(调用 POST /renders/{id}/smart-cover),不再依赖 lipsync 状态
|
||||
* - 修复点 2 次 bug:内部维护 smartCoverLoading,不依赖外层异步 state 更新
|
||||
* AI数字人 — 面板5 / 封面选择弹窗内容:
|
||||
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段;
|
||||
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。
|
||||
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览),
|
||||
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。
|
||||
*
|
||||
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
|
||||
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。
|
||||
*/
|
||||
import React, { useRef, useState } from "react"
|
||||
import type { AiAvatarCoverConfig, RenderJob } from "../types"
|
||||
|
||||
type PanelVariant = "setup" | "select-cover"
|
||||
|
||||
interface PanelCoverAndGenerateProps {
|
||||
variant?: PanelVariant
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
resolution: string
|
||||
onResolutionChange: (r: string) => void
|
||||
isGenerating: boolean
|
||||
onGenerate: () => void
|
||||
resolution?: string
|
||||
onResolutionChange?: (r: string) => void
|
||||
isGenerating?: boolean
|
||||
onGenerate?: () => void
|
||||
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */
|
||||
renderJob: RenderJob | null
|
||||
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */
|
||||
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
|
||||
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */
|
||||
onUploadCover?: (file: File) => void
|
||||
/** 配置汇总信息 */
|
||||
summary: {
|
||||
/** 弹窗关闭回调(传入则表示在弹窗中使用,底部显示「确定」按钮) */
|
||||
onClose?: () => void
|
||||
/** 封面选好(智能抽帧/自定义上传成功)后通知父组件,参数为封面 URL */
|
||||
onCoverSelected?: (coverUrl: string) => void
|
||||
/** 配置汇总信息(仅 variant="setup" 使用) */
|
||||
summary?: {
|
||||
videoName: string | null
|
||||
voiceName: string | null
|
||||
scriptLength: number
|
||||
@@ -52,15 +58,18 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
}
|
||||
|
||||
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
variant = "setup",
|
||||
coverConfig,
|
||||
onCoverConfigChange,
|
||||
resolution,
|
||||
resolution = "720p",
|
||||
onResolutionChange,
|
||||
isGenerating,
|
||||
isGenerating = false,
|
||||
onGenerate,
|
||||
renderJob,
|
||||
onGenerateRenderSmartCover,
|
||||
onUploadCover,
|
||||
onClose,
|
||||
onCoverSelected,
|
||||
summary,
|
||||
}) => {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -72,16 +81,31 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
uploadInputRef.current?.click()
|
||||
}
|
||||
|
||||
const _applyCoverUrl = (url: string, mode: "upload" | "auto_frame") => {
|
||||
const partial: Partial<AiAvatarCoverConfig> = {
|
||||
mode,
|
||||
thumbnail_url: url,
|
||||
}
|
||||
if (mode === "auto_frame") {
|
||||
partial.smart_cover_url = url
|
||||
} else {
|
||||
partial.upload_url = url
|
||||
}
|
||||
onCoverConfigChange(partial)
|
||||
onCoverSelected?.(url)
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (onUploadCover) {
|
||||
onUploadCover(file)
|
||||
} else {
|
||||
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
|
||||
const url = URL.createObjectURL(file)
|
||||
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
|
||||
const url = URL.createObjectURL(file)
|
||||
_applyCoverUrl(url, "upload")
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
@@ -92,11 +116,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
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,
|
||||
})
|
||||
_applyCoverUrl(res.cover_url, "auto_frame")
|
||||
} else {
|
||||
// 失败由父组件 message 提示,这里不重复弹窗
|
||||
console.warn("[智能封面] 返回空 cover_url:", res.message)
|
||||
@@ -108,8 +128,8 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
|
||||
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
|
||||
@@ -122,8 +142,9 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
/** 封面区占位文字 */
|
||||
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
|
||||
|
||||
/** 封面摘要状态文本 */
|
||||
/** 配置摘要中的封面状态标签 */
|
||||
const coverSummaryNode = (() => {
|
||||
if (!summary) return null
|
||||
if (summary.coverStatus === "selected") {
|
||||
return <span className="aa-config-summary__value">已选择</span>
|
||||
}
|
||||
@@ -133,6 +154,73 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
return <span className="aa-config-summary__empty">生成视频后可选</span>
|
||||
})()
|
||||
|
||||
// ── 封面选择区(两种 variant 共用) ─────────────────────────────────
|
||||
const coverSection = (
|
||||
<div className="aa-cover-section" style={{ marginTop: variant === "select-cover" ? 0 : 16 }}>
|
||||
<div className="aa-label" style={{ marginBottom: 8 }}>
|
||||
{variant === "select-cover" ? "选择封面" : "封面"}
|
||||
</div>
|
||||
{/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */}
|
||||
<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>}
|
||||
</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>
|
||||
)
|
||||
|
||||
// ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ──
|
||||
if (variant === "select-cover") {
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{coverSection}
|
||||
{onClose && (
|
||||
<div style={{ marginTop: 16, display: "flex", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
onClick={onClose}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ──
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{/* 分辨率选择 */}
|
||||
@@ -141,7 +229,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
<select
|
||||
className="aa-select"
|
||||
value={resolution}
|
||||
onChange={(e) => onResolutionChange(e.target.value)}
|
||||
onChange={(e) => onResolutionChange?.(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{RESOLUTION_OPTIONS.map((opt) => (
|
||||
@@ -157,7 +245,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
<div className="aa-config-summary">
|
||||
<div className="aa-config-summary__row">
|
||||
<span>出镜视频</span>
|
||||
{summary.videoName ? (
|
||||
{summary?.videoName ? (
|
||||
<span className="aa-config-summary__value">{summary.videoName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
@@ -165,7 +253,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>音色</span>
|
||||
{summary.voiceName ? (
|
||||
{summary?.voiceName ? (
|
||||
<span className="aa-config-summary__value">{summary.voiceName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
@@ -173,7 +261,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>文案</span>
|
||||
{summary.scriptLength > 0 ? (
|
||||
{summary && summary.scriptLength > 0 ? (
|
||||
<span className="aa-config-summary__value">{summary.scriptLength} 字</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
@@ -190,12 +278,12 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
<div className="aa-config-summary__row">
|
||||
<span>B-roll 画面插入</span>
|
||||
<span className="aa-config-summary__value">
|
||||
{summary.brollCount > 0 ? `${summary.brollCount} 段` : "无"}
|
||||
{summary && summary.brollCount > 0 ? `${summary.brollCount} 段` : "无"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>标题</span>
|
||||
{summary.hasTitle ? (
|
||||
{summary?.hasTitle ? (
|
||||
<span className="aa-config-summary__value">已设置</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
@@ -216,7 +304,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
>
|
||||
{isGenerating ? "⏳ 生成中..." : "🚀 开始生成视频"}
|
||||
</button>
|
||||
{summary.lipsyncStatus !== "completed" && !isGenerating && (
|
||||
{summary?.lipsyncStatus !== "completed" && !isGenerating && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
|
||||
请先完成对口型生成
|
||||
</div>
|
||||
@@ -228,49 +316,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面区域(视频生成后才激活;步骤③④要求:按钮在封面上方,完成后再显示封面区) */}
|
||||
<div className="aa-cover-section" style={{ marginTop: 16 }}>
|
||||
<div className="aa-label" style={{ marginBottom: 8 }}>
|
||||
封面
|
||||
</div>
|
||||
{/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */}
|
||||
<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>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -519,8 +519,8 @@ class TestAiAvatarRenderService:
|
||||
# 不应执行渲染逻辑
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
def test_execute_render_success_creates_clip_record(self):
|
||||
"""execute_render 完成后自动创建成片记录到成片库."""
|
||||
def test_execute_render_completed_does_not_auto_persist(self):
|
||||
"""execute_render 完成后不自动入库成片库(改为用户点「完成」时由 finalize_job 入库)."""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = _make_mock_db()
|
||||
@@ -549,36 +549,139 @@ class TestAiAvatarRenderService:
|
||||
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="https://oss/smart_cover.jpg"
|
||||
),
|
||||
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
|
||||
):
|
||||
import subprocess as _sp
|
||||
|
||||
mock_run.return_value = _sp.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||
tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir")
|
||||
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
svc.execute_render("render-ok")
|
||||
|
||||
# 状态应为 completed,但没有自动入库
|
||||
assert mock_job.status == "completed"
|
||||
gv_create.assert_not_called()
|
||||
assert mock_job.output_video_url.startswith("https://oss/")
|
||||
|
||||
def test_finalize_job_persists_to_library(self):
|
||||
"""finalize_job 在用户点「完成」后写入成片库,thumbnail_url 使用 job.output_cover_url."""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_job = _make_mock_render_job(
|
||||
job_id="render-finalize",
|
||||
status="completed",
|
||||
output_video_url="https://oss/ai-avatar/render-finalize/output.mp4",
|
||||
output_cover_url="https://oss/cover.jpg",
|
||||
output_duration=12.0,
|
||||
)
|
||||
|
||||
# get_render_job → db.query(AiAvatarRenderJob).filter().first() 返回 mock_job
|
||||
# finalize 幂等检查 → db.query(GeneratedVideoModel).filter().first() 返回 None(未入库)
|
||||
def _query_side_effect(model):
|
||||
q = MagicMock()
|
||||
if model.__name__ == "AiAvatarRenderJob":
|
||||
q.filter.return_value.first.return_value = mock_job
|
||||
else:
|
||||
# GeneratedVideoModel
|
||||
q.filter.return_value.first.return_value = None
|
||||
return q
|
||||
|
||||
mock_db.query.side_effect = _query_side_effect
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
|
||||
with (
|
||||
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
|
||||
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)
|
||||
|
||||
mock_clip = MagicMock()
|
||||
mock_clip.id = "clip-001"
|
||||
mock_clip.id = "clip-new"
|
||||
mock_clip.thumbnail_url = "https://oss/cover.jpg"
|
||||
gv_create.return_value = mock_clip
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.create.return_value = mock_clip
|
||||
mock_repo.get.return_value = mock_clip
|
||||
repo_cls.return_value = mock_repo
|
||||
|
||||
svc.execute_render("render-ok")
|
||||
video = svc.finalize_job("render-finalize", "user-1")
|
||||
assert video.id == "clip-new"
|
||||
gv_create.assert_called_once()
|
||||
call_kwargs = gv_create.call_args.kwargs
|
||||
assert call_kwargs["file_url"].endswith("output.mp4")
|
||||
assert call_kwargs["thumbnail_url"] == "https://oss/cover.jpg"
|
||||
assert call_kwargs["generation_task_id"] == "render-finalize"
|
||||
mock_repo.create.assert_called_once()
|
||||
|
||||
assert mock_job.status == "completed"
|
||||
gv_create.assert_called_once()
|
||||
call_kwargs = gv_create.call_args
|
||||
assert "https://oss/" in call_kwargs.kwargs["file_url"]
|
||||
assert call_kwargs.kwargs["user_id"] == "user-1"
|
||||
mock_repo.create.assert_called_once_with(mock_clip)
|
||||
def test_finalize_job_idempotent_when_already_persisted(self):
|
||||
"""finalize_job 重复调用:幂等检查命中后直接返回已有记录,不再 create."""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_job = _make_mock_render_job(
|
||||
job_id="render-finalize-2",
|
||||
status="completed",
|
||||
output_video_url="https://oss/output.mp4",
|
||||
output_cover_url="https://oss/cover.jpg",
|
||||
output_duration=12.0,
|
||||
)
|
||||
existing_model = MagicMock()
|
||||
existing_model.id = "clip-existing"
|
||||
existing_model.thumbnail_url = "https://oss/cover.jpg"
|
||||
|
||||
def _query_side_effect(model):
|
||||
q = MagicMock()
|
||||
if model.__name__ == "AiAvatarRenderJob":
|
||||
q.filter.return_value.first.return_value = mock_job
|
||||
else:
|
||||
q.filter.return_value.first.return_value = existing_model
|
||||
return q
|
||||
|
||||
mock_db.query.side_effect = _query_side_effect
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
|
||||
with (
|
||||
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
||||
) as repo_cls,
|
||||
):
|
||||
mock_existing = MagicMock()
|
||||
mock_existing.id = "clip-existing"
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_existing
|
||||
repo_cls.return_value = mock_repo
|
||||
|
||||
video = svc.finalize_job("render-finalize-2", "user-1")
|
||||
assert video.id == "clip-existing"
|
||||
gv_create.assert_not_called()
|
||||
mock_repo.create.assert_not_called()
|
||||
|
||||
def test_finalize_job_requires_completed_status(self):
|
||||
"""finalize_job 在非 completed 状态下抛异常."""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_job = _make_mock_render_job(
|
||||
job_id="render-pending",
|
||||
status="processing",
|
||||
output_video_url="",
|
||||
output_cover_url="",
|
||||
output_duration=0.0,
|
||||
)
|
||||
filter_mock = MagicMock()
|
||||
filter_mock.first.return_value = mock_job
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value = filter_mock
|
||||
mock_db.query.return_value = query_mock
|
||||
|
||||
svc = AiAvatarRenderService(mock_db)
|
||||
with pytest.raises(AiAvatarRenderError):
|
||||
svc.finalize_job("render-pending", "user-1")
|
||||
|
||||
def test_execute_render_clip_failure_does_not_affect_render(self):
|
||||
"""成片创建失败不影响渲染任务标记为成功."""
|
||||
@@ -610,7 +713,6 @@ class TestAiAvatarRenderService:
|
||||
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", side_effect=RuntimeError("DB error")),
|
||||
):
|
||||
import subprocess as _sp
|
||||
|
||||
@@ -619,7 +721,7 @@ class TestAiAvatarRenderService:
|
||||
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
|
||||
svc.execute_render("render-clip-fail")
|
||||
|
||||
# 即使成片创建失败,渲染任务仍应标记为 completed
|
||||
# 渲染任务仍应标记为 completed(不入库不影响渲染成功)
|
||||
assert mock_job.status == "completed"
|
||||
|
||||
def test_error_exception_has_code(self):
|
||||
|
||||
Reference in New Issue
Block a user