feat(ai-avatar): 渲染进度弹窗 + 实时百分比轮询 #1839
@@ -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<Record<PanelKey, boolean>>({
|
||||
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<ReturnType<typeof setInterval> | null>(null)
|
||||
/* ── 渲染进度轮询 ── */
|
||||
const renderTimerRef = useRef<ReturnType<typeof setInterval> | 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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 渲染进度弹窗 */}
|
||||
{showRenderModal && (
|
||||
<div className="aa-modal-overlay">
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">视频渲染</span>
|
||||
<button className="aa-modal__close" onClick={handleCancelRender}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="aa-modal__body"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
padding: "40px 20px",
|
||||
}}
|
||||
>
|
||||
{renderStatus === "generating" && (
|
||||
<>
|
||||
<div className="aa-lipsync-spinner" />
|
||||
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
|
||||
正在生成视频,请稍后
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: "#1890ff",
|
||||
}}
|
||||
>
|
||||
{renderProgress}%
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
width: "80%",
|
||||
height: 8,
|
||||
backgroundColor: "#f0f0f0",
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${renderProgress}%`,
|
||||
height: "100%",
|
||||
backgroundColor: "#1890ff",
|
||||
borderRadius: 4,
|
||||
transition: "width 0.5s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: "#8c8ca1" }}>
|
||||
请勿关闭页面,完成后将自动提示
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{renderStatus === "completed" && (
|
||||
<>
|
||||
<div style={{ fontSize: 48 }}>✅</div>
|
||||
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
|
||||
视频已保存到成片库
|
||||
</div>
|
||||
<button
|
||||
className="aa-btn"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => {
|
||||
setShowRenderModal(false)
|
||||
navigate("/app/products")
|
||||
}}
|
||||
>
|
||||
📁 查看成片
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{renderStatus === "failed" && (
|
||||
<>
|
||||
<div style={{ fontSize: 48 }}>❌</div>
|
||||
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>视频渲染失败</div>
|
||||
{renderErrorMessage && (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: "#ff4d4f" }}>
|
||||
{renderErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-modal__footer">
|
||||
{renderStatus === "generating" && (
|
||||
<button className="aa-btn aa-btn--danger" onClick={handleCancelRender}>
|
||||
关闭窗口
|
||||
</button>
|
||||
)}
|
||||
{renderStatus !== "generating" && (
|
||||
<button className="aa-btn" onClick={handleCancelRender}>
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user