fix(ai-avatar): 智能封面抽帧超时修复 + 封面流程改造(选封面后点完成才入库) #1877

Merged
xiaoxia merged 2 commits from fix/ai-avatar-cover-flow-0913 into develop 2026-09-13 19:32:44 +08:00
10 changed files with 669 additions and 158 deletions
@@ -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)}")
+8
View File
@@ -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
+17
View File
@@ -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;
}
+180 -20
View File
@@ -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>
)
}
+125 -23
View File
@@ -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):