fix(viral-video): #2041 删除口型同步阶段 + 分步按钮交互(开始分析/确认文案生成视频)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m27s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m9s
AI Code Review / AI Code Review (pull_request) Successful in 6m55s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m37s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 21m49s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m11s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m31s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m27s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m9s
AI Code Review / AI Code Review (pull_request) Successful in 6m55s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m37s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 21m49s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m11s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m31s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user