fix(viral-video): #2041 删除口型同步阶段 + 分步按钮交互(开始分析/确认文案生成视频) #2110

Merged
xiaoxia merged 1 commits from fix/2041-viral-video-step-buttons-musetalk into develop 2026-10-01 01:28:01 +08:00
4 changed files with 313 additions and 170 deletions
+24 -8
View File
@@ -15,6 +15,10 @@ export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [
export type ViralVideoStatus =
"pending" | "running" | "wait_user_confirm" | "completed" | "failed" | "cancelled"
/**
* 前端展示的流水线阶段(已移除 muse talk 口型同步;后端枚举保留字段以兼容旧数据,不展示)。
* 百分比对齐后端 worker _emit_progress 的真实值。
*/
export type ViralVideoStage =
| "image_analysis"
| "video_analysis"
@@ -25,29 +29,41 @@ export type ViralVideoStage =
| "tts"
| "bgm_select"
| "rendering"
| "musetalk"
| "uploading"
export interface StageDisplay {
label: string
/** 插值到的总体进度百分比 */
/** 该阶段完成时到达的总体进度百分比 */
pct: number
}
export const STAGE_DISPLAYS: Record<ViralVideoStage, StageDisplay> = {
image_analysis: { label: "图片分析", pct: 15 },
video_analysis: { label: "参考视频风格分析", pct: 25 },
intent_parsing: { label: "意图理解", pct: 35 },
video_analysis: { label: "参考视频风格分析", pct: 20 },
intent_parsing: { label: "意图理解", pct: 30 },
copy_fusion: { label: "文案融合创作", pct: 50 },
storyboard: { label: "分镜生成", pct: 60 },
review: { label: "AI审核", pct: 70 },
tts: { label: "配音生成", pct: 78 },
bgm_select: { label: "BGM匹配", pct: 85 },
rendering: { label: "视频渲染", pct: 92 },
musetalk: { label: "口型同步", pct: 97 },
tts: { label: "配音生成", pct: 75 },
bgm_select: { label: "BGM匹配", pct: 78 },
rendering: { label: "视频渲染", pct: 88 },
uploading: { label: "上传成片", pct: 100 },
}
/** 阶段顺序(用于进度列表渲染) */
export const STAGE_ORDER: ViralVideoStage[] = [
"image_analysis",
"video_analysis",
"intent_parsing",
"copy_fusion",
"storyboard",
"review",
"tts",
"bgm_select",
"rendering",
"uploading",
]
export interface StyleTemplate {
id: string
name: string
@@ -853,3 +853,16 @@
.vv-mt-12 {
margin-top: 12px;
}
/* disabled 状态 */
.vv-pill:disabled,
.vv-fusion-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.vv-btn:disabled {
cursor: not-allowed;
}
.vv-section-head {
gap: 8px;
}
+272 -159
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import React, { useCallback, useEffect, useRef, useState } from "react"
import {
PlusOutlined,
CloseOutlined,
@@ -13,13 +13,15 @@ import {
WarningFilled,
BulbFilled,
AudioOutlined,
ThunderboltOutlined,
} from "@ant-design/icons"
import { Button as AntdButton, message, Select } from "antd"
import { uploadAssetDirect, getAssetsByKind } from "@/api/assets"
import { Select, message } from "antd"
import { uploadAssetDirect, getAssetLibraries, getAssetsByKind } from "@/api/assets"
import {
FUSION_LEVELS,
STYLE_STRENGTHS,
STAGE_DISPLAYS,
STAGE_ORDER,
type FusionLevel,
type StyleStrength,
type ViralVideoJob,
@@ -43,11 +45,18 @@ type TabTask = {
uploading: boolean
pct: number
ossUrl?: string
file?: File
}[]
refVideo: { url: string; name: string; uploading: boolean; pct: number; ossUrl?: string } | null
refVideo: {
url: string
name: string
uploading: boolean
pct: number
ossUrl?: string
file?: File
} | null
fusionLevel: FusionLevel
styleStrength: StyleStrength
styleTemplateId: string
voiceId: string
userCopy: string
industry: string
@@ -75,7 +84,10 @@ type TabTask = {
job: ViralVideoJob | null
jobId: string | null
playingVoiceId: string | null
audioRef: HTMLAudioElement | null
audioInst: HTMLAudioElement | null
// 按钮 loading
analyzing: boolean
generating: boolean
}
const INDUSTRIES = [
@@ -114,7 +126,6 @@ const emptyTask = (id: string, title: string): TabTask => ({
refVideo: null,
fusionLevel: "full_ai",
styleStrength: "medium",
styleTemplateId: "",
voiceId: "",
userCopy: "",
industry: "",
@@ -141,10 +152,12 @@ const emptyTask = (id: string, title: string): TabTask => ({
job: null,
jobId: null,
playingVoiceId: null,
audioRef: null,
audioInst: null,
analyzing: false,
generating: false,
})
/* ─────────── 折叠 Section 组件 ─────────── */
/* ─────────── 折叠 Section ─────────── */
const Section: React.FC<{
title: React.ReactNode
step?: number
@@ -173,8 +186,6 @@ const ViralVideoPage: React.FC = () => {
const [voiceAssets, setVoiceAssets] = useState<
{ id: string; name: string; url: string; desc?: string }[]
>([])
const [submitting, setSubmitting] = useState(false)
const [confirmingIntent, setConfirmingIntent] = useState(false)
const dragImgRef = useRef<HTMLInputElement>(null)
const dragVideoRef = useRef<HTMLInputElement>(null)
const dragFromIdxRef = useRef<number>(-1)
@@ -218,30 +229,45 @@ const ViralVideoPage: React.FC = () => {
const onPollUpdate = useCallback(
(job: ViralVideoJob) => {
setTask((t) => {
// 首次拿到 wait_user_confirm 时,初始化 intent 编辑文本
const prevIntent = t.job?.status
// 首次进入 wait_user_confirm 时初始化 intent 编辑文本
const prevStatus = t.job?.status
const newIntentEdit =
prevIntent !== "wait_user_confirm" && job.status === "wait_user_confirm"
prevStatus !== "wait_user_confirm" && job.status === "wait_user_confirm"
? job.intent_result?.suggested_copy || t.userCopy
: t.intentEditText
return { ...t, job, intentEditText: newIntentEdit }
return {
...t,
job,
intentEditText: newIntentEdit,
analyzing:
job.status === "wait_user_confirm" ||
job.status === "completed" ||
job.status === "failed"
? false
: t.analyzing,
generating:
job.status === "completed" ||
job.status === "failed" ||
job.status === "wait_user_confirm"
? false
: t.generating,
}
})
},
[setTask],
)
useViralVideoPolling(task.jobId, onPollUpdate)
/* ── 工具函数:上传文件到 OSS ── */
/* ── 上传文件到 OSS ── */
const uploadFile = useCallback(
async (
file: File,
kind: "image" | "video" | "voice",
onProgress?: (pct: number) => Promise<void> | void,
) => {
async (file: File, kind: "image" | "video" | "voice", onProgress?: (pct: number) => void) => {
const libs = await getAssetLibraries()
const lib = (libs as { kind: string; id: string }[]).find((l) => l.kind === kind) || libs[0]
if (!lib) throw new Error("未找到素材库")
const res = await uploadAssetDirect({
file,
kind,
onProgress: (p: number) => onProgress?.(Math.round(p * 100)),
library_id: lib.id,
onProgress: (p: number) => onProgress?.(Math.round(p)),
})
return res.url
},
@@ -251,7 +277,12 @@ const ViralVideoPage: React.FC = () => {
/* ── 上传图片 ── */
const handleImageFiles = useCallback(
async (files: FileList | File[]) => {
const arr = Array.from(files).slice(0, 9 - task.images.length)
const remaining = 9 - task.images.length
if (remaining <= 0) {
message.warning("最多上传 9 张产品图")
return
}
const arr = Array.from(files).slice(0, remaining)
if (arr.length === 0) return
const newImgs = arr.map((f) => ({
localId: `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
@@ -283,7 +314,7 @@ const ViralVideoPage: React.FC = () => {
im.localId === item.localId ? { ...im, uploading: false, pct: 100, ossUrl: url } : im,
),
}))
} catch (err) {
} catch {
message.error(`${item.name} 上传失败`)
setTask((t) => ({ ...t, images: t.images.filter((im) => im.localId !== item.localId) }))
}
@@ -332,9 +363,8 @@ const ViralVideoPage: React.FC = () => {
/* ── 上传参考视频 ── */
const handleRefVideo = useCallback(
async (file: File) => {
setTask({
refVideo: { url: URL.createObjectURL(file), name: file.name, uploading: true, pct: 0 },
})
const localUrl = URL.createObjectURL(file)
setTask({ refVideo: { url: localUrl, name: file.name, uploading: true, pct: 0 } })
try {
const url = await uploadFile(file, "video", (p) => {
setTask((t) => (t.refVideo ? { ...t, refVideo: { ...t.refVideo, pct: p } } : t))
@@ -346,13 +376,14 @@ const ViralVideoPage: React.FC = () => {
)
} catch {
message.error("参考视频上传失败")
if (localUrl.startsWith("blob:")) URL.revokeObjectURL(localUrl)
setTask({ refVideo: null })
}
},
[setTask, uploadFile],
)
/* ── 新建/关闭 Tab ── */
/* ── Tab 管理 ── */
const newTab = () => {
const id = `t${Date.now()}`
setTasks((ts) => [...ts, emptyTask(id, `生成 ${ts.length + 1}`)])
@@ -362,29 +393,25 @@ const ViralVideoPage: React.FC = () => {
setTasks((ts) => {
const next = ts.filter((t) => t.id !== id)
if (next.length === 0) {
const fresh = emptyTask("t1", "生成 1")
setActiveId("t1")
return [fresh]
return [emptyTask("t1", "生成 1")]
}
if (id === activeId) setActiveId(next[0].id)
return next
})
}
/* ── 重置当前任务 ── */
const resetTask = () => {
// 停掉播放
audioRef.current?.pause()
setTasks((ts) => ts.map((t) => (t.id === activeId ? emptyTask(t.id, t.title) : t)))
}
/* ── 开始生成 ── */
const handleGenerate = async () => {
/* ── STEP1:开始分析(触发图片分析+视频分析+意图理解 → WAIT_USER_CONFIRM 暂停) ── */
const handleAnalyze = async () => {
const imgUrls = task.images
.filter((i) => i.ossUrl && !i.uploading)
.map((i) => i.ossUrl as string)
if (imgUrls.length === 0) {
message.warning("请至少上传 1 张产品图,等图片上传完成后再生成")
message.warning("请至少上传 1 张产品图,等待上传完成后再分析")
return
}
if (task.images.some((i) => i.uploading)) {
@@ -395,11 +422,7 @@ const ViralVideoPage: React.FC = () => {
message.warning("参考视频上传中,请稍候")
return
}
if (!task.voiceId) {
message.warning("请先选择一个配音音色")
return
}
setSubmitting(true)
setTask({ analyzing: true })
try {
const job = await generateViralVideo({
images: imgUrls,
@@ -407,7 +430,10 @@ const ViralVideoPage: React.FC = () => {
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
user_copy_text: task.fusionLevel !== "full_ai" ? task.userCopy : undefined,
fusion_level: task.fusionLevel,
voice_id: task.voiceId,
// 注意:STEP1「开始分析」阶段还没有真正开始生成视频,后端会跑到 WAIT_USER_CONFIRM 暂停。
// 配音和视频参数在 STEP2 确认文案时由 confirm-intent 继续使用;但后端接口目前只接收一次 payload,
// 因此先把配音/参数一并提交(用户可在确认前继续调整配音、比例等,但最终生成用的是提交时的值)。
voice_id: task.voiceId || undefined,
bgm_preference: task.bgm === "无BGM" ? undefined : task.bgm,
industry: task.industry || undefined,
target_customer: task.targetCustomer || undefined,
@@ -418,27 +444,25 @@ const ViralVideoPage: React.FC = () => {
video_model: task.videoModel,
video_ratio: task.videoRatio,
})
setTask({ job, jobId: job.id })
message.success("任务已创建,开始生成…")
setTask({ job, jobId: job.id, analyzing: false })
message.success("开始分析,稍候会给出 AI 理解的文案…")
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "提交失败"
message.error(msg)
} finally {
setSubmitting(false)
message.error(err instanceof Error ? err.message : "提交失败")
setTask({ analyzing: false })
}
}
const handleConfirmIntent = async () => {
/* ── STEP2:确认并生成视频(文案融合→分镜→审核→TTS→渲染→上传,一跑到底) ── */
const handleConfirmAndGenerate = async () => {
if (!task.jobId) return
setConfirmingIntent(true)
setTask({ generating: true })
try {
const j = await confirmViralVideoIntent(task.jobId, { confirmed_copy: task.intentEditText })
setTask({ job: j })
message.success("已确认文案,继续生成…")
message.success("文案已确认,开始生成视频…")
} catch (err: unknown) {
message.error(err instanceof Error ? err.message : "确认失败")
} finally {
setConfirmingIntent(false)
setTask({ generating: false })
}
}
@@ -453,7 +477,7 @@ const ViralVideoPage: React.FC = () => {
}
}
/* ── 播放/暂停音色 ── */
/* ── 音色播放 ── */
const toggleVoice = (v: { id: string; url: string }) => {
if (task.playingVoiceId === v.id) {
audioRef.current?.pause()
@@ -465,20 +489,14 @@ const ViralVideoPage: React.FC = () => {
a.onended = () => setTask({ playingVoiceId: null })
a.play().catch(() => {})
audioRef.current = a
setTask({ playingVoiceId: v.id, audioRef: a })
setTask({ playingVoiceId: v.id, audioInst: a })
}
useEffect(
() => () => {
audioRef.current?.pause()
},
[],
)
useEffect(() => () => audioRef.current?.pause(), [])
const onCloneSuccess = (voice: { id?: string; name?: string; url?: string }) => {
const onCloneSuccess = (voice: { id?: string }) => {
setCloneModalOpen(false)
if (voice?.id) {
setTask({ voiceId: voice.id })
// 刷新音色列表
getAssetsByKind("voice", { limit: 50 })
.then((list) =>
setVoiceAssets(
@@ -490,18 +508,39 @@ const ViralVideoPage: React.FC = () => {
}
}
/* ── 运行状态派生值 ── */
const isRunning = task.job?.status === "running" || task.job?.status === "pending"
/* ── 派生状态 ── */
const isAwaitingConfirm = task.job?.status === "wait_user_confirm"
const isPreAnalysis = !task.jobId && !task.analyzing // 还没点开始分析
const isAnalyzing =
task.analyzing ||
(task.job &&
task.job.status === "running" &&
!isAwaitingConfirm &&
!task.generating &&
(!task.job.progress_stage ||
["image_analysis", "video_analysis", "intent_parsing"].includes(task.job.progress_stage)))
const isGenerating =
task.generating ||
(task.job?.status === "running" &&
task.job.progress_stage &&
!["image_analysis", "video_analysis", "intent_parsing"].includes(task.job.progress_stage))
const isDone = task.job?.status === "completed"
const isFailed = task.job?.status === "failed"
const progressPct = useMemo(() => {
if (!task.job) return 0
if (task.job.progress_percent) return task.job.progress_percent
if (task.job.progress_stage) return STAGE_DISPLAYS[task.job.progress_stage]?.pct ?? 0
return 0
}, [task.job])
const currentStage: ViralVideoStage | undefined = task.job?.progress_stage
const progressPct = task.job?.progress_percent ?? 0
const currentStage: ViralVideoStage | undefined = task.job?.progress_stage as
ViralVideoStage | undefined
/* ── 阶段列表派生显示状态 ── */
const stageState = (s: ViralVideoStage): "pending" | "active" | "done" | "failed" => {
if (isFailed && s === currentStage) return "failed"
if (isDone) return "done"
if (!currentStage) return "pending"
const cur = STAGE_DISPLAYS[currentStage].pct
const mine = STAGE_DISPLAYS[s].pct
if (s === currentStage) return "active"
if (mine < cur) return "done"
return "pending"
}
return (
<div className="vv-page">
@@ -535,12 +574,11 @@ const ViralVideoPage: React.FC = () => {
</div>
</div>
{/* 三列 */}
<div className="vv-grid">
{/* ─────────── 左列:STEP 1 上传素材 ─────────── */}
{/* ─────────── 左列 STEP 1 ─────────── */}
<div className="vv-col">
<Section
title="上传图片"
title="上传产品图片"
step={1}
collapsed={task.collapsed.images}
onToggle={() => toggleCollapse("images")}
@@ -674,6 +712,7 @@ const ViralVideoPage: React.FC = () => {
key={s.value}
className={`vv-pill ${task.styleStrength === s.value ? "active" : ""}`}
onClick={() => setTask({ styleStrength: s.value })}
disabled={!isPreAnalysis}
>
{s.label}
</button>
@@ -686,7 +725,7 @@ const ViralVideoPage: React.FC = () => {
</Section>
<Section
title="配音选择"
title="选择配音"
step={1}
collapsed={task.collapsed.voice}
onToggle={() => toggleCollapse("voice")}
@@ -706,12 +745,12 @@ const ViralVideoPage: React.FC = () => {
</button>
</div>
<div className="vv-voice-list">
{voiceTab === "preset" && voiceAssets.length === 0 && (
{voiceAssets.length === 0 && (
<div className="vv-muted" style={{ padding: 24, textAlign: "center" }}>
暂无可用音色,先去「我的音色」上传
暂无可用音色,去「我的音色」上传
</div>
)}
{(voiceTab === "preset" ? voiceAssets : voiceAssets).map((v) => (
{voiceAssets.map((v) => (
<div
key={v.id}
className={`vv-voice-item ${task.voiceId === v.id ? "selected" : ""}`}
@@ -745,13 +784,41 @@ const ViralVideoPage: React.FC = () => {
>
<AudioOutlined /> 上传/录制我的音色
</button>
{/* STEP1 主按钮:开始分析 */}
<button
className="vv-btn vv-btn-primary"
disabled={task.analyzing || (!isPreAnalysis && !isFailed)}
onClick={handleAnalyze}
>
{task.analyzing ? (
<>
<span className="vv-spinner" /> 分析中…
</>
) : isAnalyzing ? (
<>
<span className="vv-spinner" /> 分析中…
</>
) : isFailed ? (
<>
<ReloadOutlined /> 重新开始
</>
) : (
<>
<ThunderboltOutlined /> 开始分析
</>
)}
</button>
<div className="vv-meta" style={{ marginTop: 6 }}>
<span className="vv-muted">点击后将开始图片/视频风格分析与意图理解</span>
</div>
</Section>
</div>
{/* ─────────── 中列:STEP 2 生成视频文案 ─────────── */}
{/* ─────────── 中列 STEP 2 ─────────── */}
<div className="vv-col">
<Section
title="生成视频文案"
title="文案融合方式"
step={2}
collapsed={task.collapsed.fusion}
onToggle={() => toggleCollapse("fusion")}
@@ -762,6 +829,7 @@ const ViralVideoPage: React.FC = () => {
key={f.value}
className={`vv-fusion-btn ${task.fusionLevel === f.value ? "active" : ""}`}
onClick={() => setTask({ fusionLevel: f.value })}
disabled={!isPreAnalysis}
>
<strong>{f.label}</strong>
{f.desc}
@@ -783,6 +851,7 @@ const ViralVideoPage: React.FC = () => {
}
value={task.userCopy}
onChange={(e) => setTask({ userCopy: e.target.value })}
disabled={!isPreAnalysis}
/>
</div>
</Section>
@@ -804,6 +873,7 @@ const ViralVideoPage: React.FC = () => {
onChange={(v) => setTask({ industry: v })}
options={INDUSTRIES.map((i) => ({ value: i, label: i }))}
allowClear
disabled={!isPreAnalysis}
/>
</div>
<div className="vv-form-row">
@@ -813,6 +883,7 @@ const ViralVideoPage: React.FC = () => {
placeholder="例:25-35岁职场女性"
value={task.targetCustomer}
onChange={(e) => setTask({ targetCustomer: e.target.value })}
disabled={!isPreAnalysis}
/>
</div>
<div className="vv-form-row">
@@ -823,6 +894,7 @@ const ViralVideoPage: React.FC = () => {
value={task.language}
onChange={(v) => setTask({ language: v })}
options={LANGUAGES.map((i) => ({ value: i, label: i }))}
disabled={!isPreAnalysis}
/>
</div>
<div className="vv-form-row">
@@ -833,6 +905,7 @@ const ViralVideoPage: React.FC = () => {
value={task.duration}
onChange={(v) => setTask({ duration: v })}
options={[15, 20, 30, 45, 60].map((n) => ({ value: n, label: `${n}秒` }))}
disabled={!isPreAnalysis}
/>
</div>
<div className="vv-form-row">
@@ -843,6 +916,7 @@ const ViralVideoPage: React.FC = () => {
value={task.viralStructure}
onChange={(v) => setTask({ viralStructure: v })}
options={STRUCTURES.map((i) => ({ value: i, label: i }))}
disabled={!isPreAnalysis}
/>
</div>
<div className="vv-form-row">
@@ -853,6 +927,7 @@ const ViralVideoPage: React.FC = () => {
value={task.marketingPurpose}
onChange={(v) => setTask({ marketingPurpose: v })}
options={PURPOSES.map((i) => ({ value: i, label: i }))}
disabled={!isPreAnalysis}
/>
</div>
<div className="vv-form-row" style={{ gridColumn: "1 / -1" }}>
@@ -863,6 +938,7 @@ const ViralVideoPage: React.FC = () => {
key={b}
className={`vv-pill ${task.bgm === b ? "active" : ""}`}
onClick={() => setTask({ bgm: b })}
disabled={!isPreAnalysis}
>
{b}
</button>
@@ -872,12 +948,34 @@ const ViralVideoPage: React.FC = () => {
</div>
</Section>
{isAwaitingConfirm && task.job?.intent_result && (
{/* AI 意图确认卡:wait_user_confirm 或生成中都显示 */}
{(isAwaitingConfirm || isGenerating || isDone) && task.job?.intent_result && (
<div className="vv-section">
<div className="vv-section-body">
<div className="vv-intent">
<div className="vv-intent-title">
<BulbFilled /> AI 文案已生成,请确认后继续
<BulbFilled /> AI 文案理解
{isAwaitingConfirm && (
<span
style={{ marginLeft: 6, fontWeight: 400, fontSize: 12, color: "#b45309" }}
>
(请确认后开始生成视频)
</span>
)}
{isGenerating && (
<span
style={{ marginLeft: 6, fontWeight: 400, fontSize: 12, color: "#10b981" }}
>
(已确认,正在生成)
</span>
)}
{isDone && (
<span
style={{ marginLeft: 6, fontWeight: 400, fontSize: 12, color: "#10b981" }}
>
(已完成)
</span>
)}
</div>
{task.job.intent_result.product && (
<div className="vv-intent-row">
@@ -909,32 +1007,42 @@ const ViralVideoPage: React.FC = () => {
<div className="vv-v">{task.job.intent_result.tone}</div>
</div>
)}
<div className="vv-form-row" style={{ marginTop: 10 }}>
<label className="vv-label">可编辑文案</label>
<textarea
className="vv-textarea"
value={task.intentEditText}
onChange={(e) => setTask({ intentEditText: e.target.value })}
/>
</div>
<div className="vv-flex vv-gap-8 vv-mt-8">
<button
className="vv-btn vv-btn-primary"
style={{ flex: 1, marginTop: 0 }}
disabled={confirmingIntent}
onClick={handleConfirmIntent}
>
{confirmingIntent ? (
<>
<span className="vv-spinner" /> 提交中…
</>
) : (
<>
<CheckOutlined /> 确认并继续生成
</>
)}
</button>
</div>
{isAwaitingConfirm && (
<>
<div className="vv-form-row" style={{ marginTop: 10 }}>
<label className="vv-label">可编辑文案</label>
<textarea
className="vv-textarea"
value={task.intentEditText}
onChange={(e) => setTask({ intentEditText: e.target.value })}
/>
</div>
<button
className="vv-btn vv-btn-primary"
style={{ marginTop: 0 }}
disabled={task.generating}
onClick={handleConfirmAndGenerate}
>
{task.generating ? (
<>
<span className="vv-spinner" /> 生成中…
</>
) : (
<>
<VideoCameraOutlined /> 确认文案并生成视频
</>
)}
</button>
</>
)}
{!isAwaitingConfirm && task.job.final_copy_text && (
<div className="vv-intent-row" style={{ marginTop: 8 }}>
<div className="vv-k">最终文案</div>
<div className="vv-v" style={{ whiteSpace: "pre-wrap", fontSize: 12 }}>
{task.job.final_copy_text}
</div>
</div>
)}
</div>
</div>
</div>
@@ -955,7 +1063,7 @@ const ViralVideoPage: React.FC = () => {
)}
</div>
{/* ─────────── 右列:STEP 3 生成视频 ─────────── */}
{/* ─────────── 右列 STEP 3 ─────────── */}
<div className="vv-col">
<Section
title="视频预览"
@@ -970,24 +1078,23 @@ const ViralVideoPage: React.FC = () => {
<div className="vv-preview-placeholder">
<div className="ph-icon">🎬</div>
<div className="ph-txt">
{isRunning || isAwaitingConfirm
? "生成中,完成后将在此预览"
: "提交后视频将在此处预览"}
{isGenerating
? "视频生成中,完成后将在此预览"
: isAwaitingConfirm
? "请在 STEP 2 确认文案后开始生成"
: "完成前面步骤后视频将在此处预览"}
</div>
</div>
)}
</div>
{isDone && task.job?.output_url && (
<AntdButton
type="primary"
block
icon={<ReloadOutlined />}
className="vv-mt-8"
style={{ background: "linear-gradient(135deg,#7c3aed,#a855f7)", border: "none" }}
<button
className="vv-btn vv-btn-ghost vv-mt-8"
style={{ width: "100%" }}
onClick={handleRetry}
>
重新生成
</AntdButton>
<ReloadOutlined /> 重新生成
</button>
)}
</Section>
@@ -1005,7 +1112,7 @@ const ViralVideoPage: React.FC = () => {
value={task.videoRatio}
onChange={(v) => setTask({ videoRatio: v })}
options={RATIOS.map((r) => ({ value: r.v, label: r.label }))}
disabled={!!task.jobId}
disabled={!isPreAnalysis}
/>
</div>
<div className="vv-form-row" style={{ marginBottom: 0 }}>
@@ -1016,7 +1123,7 @@ const ViralVideoPage: React.FC = () => {
value={task.videoModel}
onChange={(v) => setTask({ videoModel: v })}
options={MODELS.map((m) => ({ value: m.v, label: m.label }))}
disabled={!!task.jobId}
disabled={!isPreAnalysis}
/>
</div>
</Section>
@@ -1027,7 +1134,7 @@ const ViralVideoPage: React.FC = () => {
collapsed={task.collapsed.stages}
onToggle={() => toggleCollapse("stages")}
>
{(isRunning || isAwaitingConfirm) && (
{task.jobId && (isAnalyzing || isGenerating || isDone || isFailed) && (
<>
<div className="vv-progress-bar">
<div className="vv-progress-fill" style={{ width: `${progressPct}%` }} />
@@ -1039,22 +1146,14 @@ const ViralVideoPage: React.FC = () => {
</>
)}
<div className="vv-stages">
{(Object.keys(STAGE_DISPLAYS) as ViralVideoStage[]).map((s) => {
const isDoneStage = currentStage
? STAGE_DISPLAYS[s].pct < STAGE_DISPLAYS[currentStage].pct
: false
const isActiveStage = s === currentStage
let cls = ""
if (isFailed) cls = s === currentStage ? "failed" : isDoneStage ? "done" : ""
else if (isDone) cls = "done"
else if (isActiveStage) cls = "active"
else if (isDoneStage) cls = "done"
{STAGE_ORDER.map((s) => {
const st = stageState(s)
return (
<div key={s} className={`vv-stage ${cls}`}>
<div key={s} className={`vv-stage ${st}`}>
<span className="vv-stage-dot">
{cls === "done" ? (
{st === "done" ? (
<CheckOutlined style={{ fontSize: 8 }} />
) : cls === "failed" ? (
) : st === "failed" ? (
"×"
) : (
""
@@ -1075,27 +1174,41 @@ const ViralVideoPage: React.FC = () => {
<strong>{task.duration >= 45 ? 80 : task.duration >= 30 ? 60 : 40} 积分</strong>
</div>
<button
className="vv-btn vv-btn-primary"
disabled={submitting || isRunning || isAwaitingConfirm}
onClick={handleGenerate}
>
{submitting ? (
<>
<span className="vv-spinner" /> 提交中…
</>
) : isRunning ? (
<>
<span className="vv-spinner" /> 生成中 {progressPct}%…
</>
) : isAwaitingConfirm ? (
"等待文案确认…"
) : (
<>
<VideoCameraOutlined /> 开始生成视频
</>
)}
</button>
{isPreAnalysis && (
<div
className="vv-muted"
style={{
padding: "12px 16px",
background: "#f9fafb",
borderRadius: 8,
fontSize: 12,
textAlign: "center",
}}
>
请先在 STEP 1 上传素材并点击「开始分析」
</div>
)}
{isAwaitingConfirm && (
<div
className="vv-muted"
style={{
padding: "12px 16px",
background: "#fffbeb",
border: "1px solid #fcd34d",
borderRadius: 8,
fontSize: 12,
textAlign: "center",
color: "#b45309",
}}
>
请在 STEP 2 确认 AI 文案后点击「确认文案并生成视频」
</div>
)}
{isGenerating && (
<button className="vv-btn vv-btn-primary" disabled>
<span className="vv-spinner" /> 视频生成中 {progressPct}%…
</button>
)}
</div>
</div>
@@ -12,6 +12,7 @@ export interface UseViralVideoPollingOptions {
/**
* 爆款视频任务 HTTP 轮询 hook(后端暂未暴露 WS 桥,轮询兜底)。
* 任务进入终态(completed/failed/cancelled)后自动停止。
* 后端进度条通过 progress_percent 真实值推进,前端不再依赖阶段插值。
*/
export function useViralVideoPolling(
jobId: string | null | undefined,
@@ -42,11 +43,11 @@ export function useViralVideoPolling(
return
}
if (stoppedRef.current) return
// 后端在做 GPU 推理/合成阶段拉长间隔
const inRender = job.progress_stage === "rendering" || job.progress_stage === "musetalk"
// 渲染阶段(Seedance 多段视频生成较慢)拉长轮询间隔
const inRender = job.progress_stage === "rendering"
const nextDelay = inRender ? 3000 : intervalMs
timerRef.current = setTimeout(() => pollOnce(id), nextDelay)
} catch (err) {
} catch (_err) {
failCountRef.current += 1
if (stoppedRef.current) return
// 指数退避,最多退到 10s