diff --git a/apps/api/app/api/routes/ai_avatar_render.py b/apps/api/app/api/routes/ai_avatar_render.py index a031fb8d2..988e1e19e 100644 --- a/apps/api/app/api/routes/ai_avatar_render.py +++ b/apps/api/app/api/routes/ai_avatar_render.py @@ -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)}") diff --git a/apps/api/app/schemas/ai_avatar_render.py b/apps/api/app/schemas/ai_avatar_render.py index d3a2e0a30..0a6cfa95d 100644 --- a/apps/api/app/schemas/ai_avatar_render.py +++ b/apps/api/app/schemas/ai_avatar_render.py @@ -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") diff --git a/apps/api/app/services/ai_avatar_cover_service.py b/apps/api/app/services/ai_avatar_cover_service.py index a17a000b0..d1dc22167 100644 --- a/apps/api/app/services/ai_avatar_cover_service.py +++ b/apps/api/app/services/ai_avatar_cover_service.py @@ -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 diff --git a/apps/api/app/services/ai_avatar_render_service.py b/apps/api/app/services/ai_avatar_render_service.py index 41a052fc2..ca6965b0c 100644 --- a/apps/api/app/services/ai_avatar_render_service.py +++ b/apps/api/app/services/ai_avatar_render_service.py @@ -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 diff --git a/apps/web/src/pages/ai-avatar/AiAvatar.css b/apps/web/src/pages/ai-avatar/AiAvatar.css index 2701d830f..1fb146ddb 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatar.css +++ b/apps/web/src/pages/ai-avatar/AiAvatar.css @@ -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; +} diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 01c811c9e..a7f80c41c 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -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(null) + /* ── 封面选择弹窗 ── */ + const [showCoverModal, setShowCoverModal] = useState(false) + const [selectedCoverUrl, setSelectedCoverUrl] = useState("") + const [finalizeLoading, setFinalizeLoading] = useState(false) /* ── 对口型轮询 ── */ const lipsyncTimerRef = useRef | 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 = () => { - {/* 面板5:封面 & 生成 */} + {/* 面板5:封面 & 生成 + - 渲染未完成:显示分辨率/配置摘要/「开始生成」按钮(PanelCoverAndGenerate setup 变体,无封面区) + - 渲染完成:显示封面预览 + 「🎬 选择封面」/「✅ 完成」按钮,封面选择在弹窗中完成 */}
togglePanel("cover")}> - 封面 & 生成 + + {currentRenderJob?.status === "completed" ? "视频已生成" : "封面 & 生成"} + ▼
- - 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" ? ( + + state.setCoverConfig((prev) => ({ ...prev, ...partial })) + } + renderJob={currentRenderJob} + onGenerateRenderSmartCover={handleGenerateRenderSmartCover} + resolution={state.resolution} + onResolutionChange={state.setResolution} + isGenerating={state.isGenerating} + onGenerate={handleGenerate} + summary={summary} + /> + ) : ( +
+
+ ✅ 视频生成完成,请选择封面后点「完成」入库 +
+
+ {selectedCoverUrl ? ( + 封面预览 + ) : ( +
+ 🎬 + 尚未选择封面 +
+ )} +
+
+ + +
+
+ )}
@@ -938,17 +1082,19 @@ const AiAvatarPage: React.FC = () => { <>
✅
- 视频已保存到成片库 + 视频生成完成,请选择封面 +
+
+ 关闭此窗口后可在下方选择封面并保存到成片库
)} @@ -979,6 +1125,20 @@ const AiAvatarPage: React.FC = () => { )} + + {/* 封面选择弹窗 */} + + state.setCoverConfig((prev) => ({ ...prev, ...partial })) + } + onGenerateRenderSmartCover={handleGenerateRenderSmartCover} + onUploadCover={handleUploadCover} + onCoverSelected={handleCoverSelected} + /> ) } diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts index fec24a5ab..ae3a4159d 100644 --- a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -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`, + ) diff --git a/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx b/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx new file mode 100644 index 000000000..55654476a --- /dev/null +++ b/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx @@ -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) => void + onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }> + onUploadCover?: (file: File) => void + onCoverSelected: (coverUrl: string) => void +} + +const ModalCoverSelect: React.FC = ({ + open, + onClose, + renderJob, + coverConfig, + onCoverConfigChange, + onGenerateRenderSmartCover, + onUploadCover, + onCoverSelected, +}) => { + if (!open) return null + + return ( +
+
e.stopPropagation()} style={{ maxWidth: 480 }}> +
+ 选择封面 + +
+
+ +
+
+
+ ) +} + +export default ModalCoverSelect diff --git a/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx b/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx index d8a22af9b..0e39795e7 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx @@ -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) => 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 = { } const PanelCoverAndGenerate: React.FC = ({ + variant = "setup", coverConfig, onCoverConfigChange, - resolution, + resolution = "720p", onResolutionChange, - isGenerating, + isGenerating = false, onGenerate, renderJob, onGenerateRenderSmartCover, onUploadCover, + onClose, + onCoverSelected, summary, }) => { const uploadInputRef = useRef(null) @@ -72,16 +81,31 @@ const PanelCoverAndGenerate: React.FC = ({ uploadInputRef.current?.click() } + const _applyCoverUrl = (url: string, mode: "upload" | "auto_frame") => { + const partial: Partial = { + 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) => { 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 = ({ 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 = ({ } } - 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 = ({ /** 封面区占位文字 */ const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面" - /** 封面摘要状态文本 */ + /** 配置摘要中的封面状态标签 */ const coverSummaryNode = (() => { + if (!summary) return null if (summary.coverStatus === "selected") { return 已选择 } @@ -133,6 +154,73 @@ const PanelCoverAndGenerate: React.FC = ({ return 生成视频后可选 })() + // ── 封面选择区(两种 variant 共用) ───────────────────────────────── + const coverSection = ( +
+
+ {variant === "select-cover" ? "选择封面" : "封面"} +
+ {/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */} +
+ {hasCoverImage ? ( + 封面预览 + ) : ( + {coverPlaceholder} + )} + {smartCoverLoading &&
⏳ 智能选帧中…
} +
+ +
+ + + +
+
+ ) + + // ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ── + if (variant === "select-cover") { + return ( +
+ {coverSection} + {onClose && ( +
+ +
+ )} +
+ ) + } + + // ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ── return (
{/* 分辨率选择 */} @@ -141,7 +229,7 @@ const PanelCoverAndGenerate: React.FC = ({ -
- ) } diff --git a/tests/unit/test_ai_avatar_render_service.py b/tests/unit/test_ai_avatar_render_service.py index d9ce7434f..223eb8eb7 100644 --- a/tests/unit/test_ai_avatar_render_service.py +++ b/tests/unit/test_ai_avatar_render_service.py @@ -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):