From afb50360d71b7fc9728d8ff8c3eba5046795dadb Mon Sep 17 00:00:00 2001 From: Coze Agent Date: Thu, 1 Oct 2026 01:03:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(viral-video):=20#2041=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=8F=A3=E5=9E=8B=E5=90=8C=E6=AD=A5=E9=98=B6=E6=AE=B5=20+=20?= =?UTF-8?q?=E5=88=86=E6=AD=A5=E6=8C=89=E9=92=AE=E4=BA=A4=E4=BA=92=EF=BC=88?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=E5=88=86=E6=9E=90/=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E6=96=87=E6=A1=88=E7=94=9F=E6=88=90=E8=A7=86=E9=A2=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api/viral-video/types.ts | 32 +- .../src/pages/viral-video/ViralVideoPage.css | 13 + .../src/pages/viral-video/ViralVideoPage.tsx | 431 +++++++++++------- .../viral-video/hooks/useViralVideoPolling.ts | 7 +- 4 files changed, 313 insertions(+), 170 deletions(-) diff --git a/apps/web/src/api/viral-video/types.ts b/apps/web/src/api/viral-video/types.ts index 3327fda2a..1ea182f55 100644 --- a/apps/web/src/api/viral-video/types.ts +++ b/apps/web/src/api/viral-video/types.ts @@ -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 = { 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 diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css index d35bd5ac3..72f60b29b 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.css +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -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; +} diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index 12455bfa2..8b498e5eb 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -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(null) const dragVideoRef = useRef(null) const dragFromIdxRef = useRef(-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, - ) => { + 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 (
@@ -535,12 +574,11 @@ const ViralVideoPage: React.FC = () => {
- {/* 三列 */}
- {/* ─────────── 左列:STEP 1 上传素材 ─────────── */} + {/* ─────────── 左列 STEP 1 ─────────── */}
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} @@ -686,7 +725,7 @@ const ViralVideoPage: React.FC = () => {
toggleCollapse("voice")} @@ -706,12 +745,12 @@ const ViralVideoPage: React.FC = () => {
- {voiceTab === "preset" && voiceAssets.length === 0 && ( + {voiceAssets.length === 0 && (
- 暂无可用音色,先去「我的音色」上传 + 暂无可用音色,去「我的音色」上传
)} - {(voiceTab === "preset" ? voiceAssets : voiceAssets).map((v) => ( + {voiceAssets.map((v) => (
{ > 上传/录制我的音色 + + {/* STEP1 主按钮:开始分析 */} + +
+ 点击后将开始图片/视频风格分析与意图理解 +
- {/* ─────────── 中列:STEP 2 生成视频文案 ─────────── */} + {/* ─────────── 中列 STEP 2 ─────────── */}
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} > {f.label} {f.desc} @@ -783,6 +851,7 @@ const ViralVideoPage: React.FC = () => { } value={task.userCopy} onChange={(e) => setTask({ userCopy: e.target.value })} + disabled={!isPreAnalysis} />
@@ -804,6 +873,7 @@ const ViralVideoPage: React.FC = () => { onChange={(v) => setTask({ industry: v })} options={INDUSTRIES.map((i) => ({ value: i, label: i }))} allowClear + disabled={!isPreAnalysis} />
@@ -813,6 +883,7 @@ const ViralVideoPage: React.FC = () => { placeholder="例:25-35岁职场女性" value={task.targetCustomer} onChange={(e) => setTask({ targetCustomer: e.target.value })} + disabled={!isPreAnalysis} />
@@ -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} />
@@ -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} />
@@ -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} />
@@ -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} />
@@ -863,6 +938,7 @@ const ViralVideoPage: React.FC = () => { key={b} className={`vv-pill ${task.bgm === b ? "active" : ""}`} onClick={() => setTask({ bgm: b })} + disabled={!isPreAnalysis} > {b} @@ -872,12 +948,34 @@ const ViralVideoPage: React.FC = () => {
- {isAwaitingConfirm && task.job?.intent_result && ( + {/* AI 意图确认卡:wait_user_confirm 或生成中都显示 */} + {(isAwaitingConfirm || isGenerating || isDone) && task.job?.intent_result && (
- AI 文案已生成,请确认后继续 + AI 文案理解 + {isAwaitingConfirm && ( + + (请确认后开始生成视频) + + )} + {isGenerating && ( + + (已确认,正在生成) + + )} + {isDone && ( + + (已完成) + + )}
{task.job.intent_result.product && (
@@ -909,32 +1007,42 @@ const ViralVideoPage: React.FC = () => {
{task.job.intent_result.tone}
)} -
- -