diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 42db7a340..8196c3d53 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -5,6 +5,7 @@ */ import React, { useState, useCallback, useEffect, useRef } from "react" import { message } from "antd" +import { useNavigate } from "react-router-dom" import "./AiAvatar.css" import { useAiAvatar } from "./hooks/useAiAvatar" import { PanelVideoSelector } from "./components/PanelVideoSelector" @@ -21,6 +22,7 @@ import { createLipsyncJob, getLipsyncJob, submitRender, + getRenderJob, generateSmartCover, } from "./api/aiAvatar" import { @@ -34,6 +36,7 @@ type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover" const AiAvatarPage: React.FC = () => { const state = useAiAvatar() + const navigate = useNavigate() const [currentStep, setCurrentStep] = useState<1 | 2>(1) const [collapsed, setCollapsed] = useState>({ video: false, @@ -52,9 +55,18 @@ const AiAvatarPage: React.FC = () => { const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("") /* ── 智能封面加载态 ── */ const [smartCoverLoading, setSmartCoverLoading] = useState(false) + /* ── 渲染进度弹窗 ── */ + const [showRenderModal, setShowRenderModal] = useState(false) + const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">( + "generating", + ) + const [renderProgress, setRenderProgress] = useState(0) + const [renderErrorMessage, setRenderErrorMessage] = useState("") /* ── 对口型轮询 ── */ const lipsyncTimerRef = useRef | null>(null) + /* ── 渲染进度轮询 ── */ + const renderTimerRef = useRef | null>(null) const togglePanel = useCallback((key: PanelKey) => { setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })) @@ -182,19 +194,19 @@ const AiAvatarPage: React.FC = () => { useEffect(() => { return () => { if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) + if (renderTimerRef.current) clearInterval(renderTimerRef.current) } }, []) - /* ── 生成视频 ── */ + /* ── 生成视频(含实时进度轮询) ── */ const handleGenerate = useCallback(async () => { - // ② 前置条件提示(#1809) if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") { message.warning("请先生成对口型视频,待对口型完成后再提交渲染") return } state.setIsGenerating(true) try { - await submitRender({ + const job = await submitRender({ lipsync_job_id: state.lipsyncJob.id, script_id: state.script?.id, b_roll_segments: state.bRollSegments.map((seg) => ({ @@ -206,12 +218,36 @@ const AiAvatarPage: React.FC = () => { pip_position: seg.pip_position, pip_scale: seg.pip_scale, })) as never, - // 字段映射:build_title_drawtext_filter 真实口径 text/font_size/font_color/position/... title_config: buildTitleConfigPayload(state.titleConfig), - // cover_config:智能封面 cover_url + 截帧 timestamp cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url), }) - message.success("渲染任务已提交,可在视频管理中查看进度") + + // 打开渲染进度弹窗,启动轮询 + setShowRenderModal(true) + setRenderStatus("generating") + setRenderProgress(job.progress ?? 0) + setRenderErrorMessage("") + + if (renderTimerRef.current) clearInterval(renderTimerRef.current) + renderTimerRef.current = setInterval(async () => { + try { + const updated = await getRenderJob(job.id) + setRenderProgress(updated.progress ?? 0) + if (updated.status === "completed") { + if (renderTimerRef.current) clearInterval(renderTimerRef.current) + renderTimerRef.current = null + setRenderStatus("completed") + message.success("视频已生成并保存到成片库") + } else if (updated.status === "failed") { + if (renderTimerRef.current) clearInterval(renderTimerRef.current) + renderTimerRef.current = null + setRenderStatus("failed") + setRenderErrorMessage(updated.error_message || "渲染失败,请重试") + } + } catch (pollErr) { + console.error("[渲染] 轮询失败:", pollErr) + } + }, 3000) } catch (err) { console.error("渲染任务提交失败:", err) message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试") @@ -221,6 +257,18 @@ const AiAvatarPage: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.lipsyncJob, state.script, state.bRollSegments, state.titleConfig, state.coverConfig]) + /* ── 关闭渲染进度弹窗 ── */ + const handleCancelRender = useCallback(() => { + if (renderTimerRef.current) { + clearInterval(renderTimerRef.current) + renderTimerRef.current = null + } + setShowRenderModal(false) + setRenderStatus("generating") + setRenderProgress(0) + setRenderErrorMessage("") + }, []) + /* ── 智能封面:调后端 MediaKit 选帧接口(#1822) ── */ const handleSmartCover = useCallback(async () => { // 基于对口型成片抽帧,必须先完成对口型 @@ -510,6 +558,112 @@ const AiAvatarPage: React.FC = () => { )} + + {/* 渲染进度弹窗 */} + {showRenderModal && ( +
+
e.stopPropagation()}> +
+ 视频渲染 + +
+
+ {renderStatus === "generating" && ( + <> +
+
+ 正在生成视频,请稍后 +
+
+ {renderProgress}% +
+
+
+
+
+ 请勿关闭页面,完成后将自动提示 +
+ + )} + {renderStatus === "completed" && ( + <> +
+
+ 视频已保存到成片库 +
+ + + )} + {renderStatus === "failed" && ( + <> +
+
视频渲染失败
+ {renderErrorMessage && ( +
+ {renderErrorMessage} +
+ )} + + )} +
+
+ {renderStatus === "generating" && ( + + )} + {renderStatus !== "generating" && ( + + )} +
+
+
+ )}
) }