diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index 16c4a4808..1fe1b6cbc 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -28,18 +28,20 @@ import { FUSION_LEVELS, STYLE_STRENGTHS, isVideoStage, + isCopyStage, + isImageAnalysisStage, type FusionLevel, type StyleStrength, type ViralVideoJob, type ImageProductAnalysis, type ImageAnalysisResult, - type CopyResult, } from "@/api/viral-video/types" import { generateViralVideo, retryViralVideo, - mockImageAnalysis, - mockGenerateCopy, + analyzeViralImages, + generateViralCopy, + confirmViralCopy, } from "@/api/viral-video" import { useViralVideoPolling } from "./hooks/useViralVideoPolling" import CloneModal from "@/components/voice/CloneModal" @@ -273,22 +275,63 @@ const ViralVideoPage: React.FC = () => { }) }, []) - /* ── 轮询(job 存在时才轮询,在视频生成阶段使用) ── */ + /* ── 轮询(job 从 STEP1 开始即存在,贯穿三步) ── */ const onPollUpdate = useCallback( (job: ViralVideoJob) => { setTask((t) => { let nextStep: UIStep = t.uiStep - if (job.status === "completed") nextStep = "step3_done" - else if (job.status === "failed") nextStep = "failed" - else if (job.status === "running") { - if (isVideoStage(job.progress_stage)) nextStep = "step3_generating" - } + let copyError = t.copyError + let videoError = t.videoError const err = job.error_message || job.error_msg + // 同步服务端最新的分析/文案结果(若已返回) + const nextImageAnalysis = job.image_analysis || t.imageAnalysis + const nextCopy = job.copy_result + const nextGeneratedCopy = + nextCopy?.final_copy || nextCopy?.suggested_copy || t.generatedCopy + const nextGeneratedTitle = nextCopy?.title || t.generatedTitle + + switch (job.status) { + case "image_analyzed": + nextStep = "step1_done" + break + case "copy_generated": + nextStep = "step2_copy_ready" + break + case "wait_user_confirm": + // 兼容旧链路:停在等待确认 + nextStep = + t.uiStep === "step3_generating" || t.uiStep === "step3_ready" + ? t.uiStep + : "step2_copy_ready" + break + case "running": + if (isVideoStage(job.progress_stage)) nextStep = "step3_generating" + else if (isCopyStage(job.progress_stage)) nextStep = "step2_generating" + else if (isImageAnalysisStage(job.progress_stage)) nextStep = "step1_analyzing" + break + case "completed": + nextStep = "step3_done" + break + case "failed": + nextStep = "failed" + if (isVideoStage(job.progress_stage)) videoError = err || videoError + else copyError = err || copyError + break + case "pending": + // 保持当前加载态 + break + } return { ...t, job, uiStep: nextStep, - videoError: nextStep === "failed" ? err || t.videoError : undefined, + imageAnalysis: nextImageAnalysis, + generatedCopy: nextGeneratedCopy, + generatedTitle: nextGeneratedTitle, + copyError: + nextStep === "failed" && isCopyStage(job.progress_stage) ? copyError : copyError, + videoError: + nextStep === "failed" && isVideoStage(job.progress_stage) ? videoError : videoError, } }) }, @@ -568,7 +611,7 @@ const ViralVideoPage: React.FC = () => { setTasks((ts) => ts.map((t) => (t.id === activeId ? emptyTask(t.id, t.title) : t))) } - /* ── STEP 1:分析图片 ── */ + /* ── STEP 1:分析图片(调用真实 analyze-images 接口,创建 job 并开始轮询) ── */ const handleAnalyzeImages = async () => { const readyImgs = task.images.filter((i) => i.ossUrl && !i.uploading) if (readyImgs.length === 0) { @@ -583,82 +626,119 @@ const ViralVideoPage: React.FC = () => { message.warning("参考视频上传中,请稍候") return } - setTask({ uiStep: "step1_analyzing" }) + setTask({ uiStep: "step1_analyzing", jobId: null, job: null }) try { - // TODO: 后端上线 analyze-only 端点后替换为真实 API(传 step:"analyze") - const result = await mockImageAnalysis(task.images) - setTask({ imageAnalysis: result, uiStep: "step1_done" }) - message.success("图片分析完成,请在 STEP 2 配置文案参数") + const job = await analyzeViralImages({ + images: readyImgs.map((i) => i.ossUrl as string), + reference_video_url: task.refVideo?.ossUrl || undefined, + style_template_id: task.refVideo?.ossUrl ? undefined : undefined, + style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined, + }) + setTask({ job, jobId: job.id }) + // 轮询会在 status=image_analyzed 时推进 uiStep + message.success("已提交图片分析…") } catch (err: unknown) { message.error(err instanceof Error ? err.message : "分析失败") setTask({ uiStep: "failed", copyError: err instanceof Error ? err.message : "分析失败" }) } } - /* ── STEP 2:生成文案 ── */ + /* ── STEP 2:生成文案(调用 generate-copy 接口,继续复用同一个 jobId 轮询) ── */ const handleGenerateCopy = async () => { + if (!task.jobId) { + // 异常兜底:没 jobId 时先触发分析 + message.warning("请先完成图片分析") + return + } setTask({ uiStep: "step2_generating", copyError: undefined }) try { - const firstProduct = task.imageAnalysis?.products?.[0] - const productName = - firstProduct?.name || task.images[0]?.name.replace(/\.[^.]+$/, "") || "产品" - // TODO: 后端上线 generate-copy 端点后替换 - const result: CopyResult = await mockGenerateCopy({ - product: productName, - tone: "亲切务实", + const job = await generateViralCopy(task.jobId, { + industry: task.industry || undefined, + target_customer: task.targetCustomer || undefined, + persona_id: undefined, + viral_structure: task.viralStructure || undefined, + marketing_purpose: task.marketingPurpose || undefined, + bgm_preference: task.bgm === "无BGM" ? undefined : task.bgm, duration: task.duration, - marketingPurpose: task.marketingPurpose, - industry: task.industry, - targetCustomer: task.targetCustomer, + user_copy_text: + task.fusionLevel === "user_primary" && task.userCopy ? task.userCopy : undefined, + fusion_level: task.fusionLevel, + style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined, }) - setTask({ - uiStep: "step2_copy_ready", - generatedCopy: result.final_copy || result.suggested_copy || "", - generatedTitle: result.title || "", - }) - message.success("文案已生成,请查看并可在下方编辑") + setTask({ job }) + // 轮询会在 status=copy_generated 时推进 uiStep } catch (err: unknown) { message.error(err instanceof Error ? err.message : "文案生成失败") setTask({ uiStep: "failed", copyError: err instanceof Error ? err.message : "文案生成失败" }) } } - /* ── STEP 2:确认文案 → 进入 STEP 3 ── */ - const handleConfirmCopy = () => { + /* ── STEP 2:确认文案 → 直接触发 STEP 3 视频生成(confirm-copy 接口) ── */ + const handleConfirmCopy = async () => { if (!task.generatedCopy.trim()) { message.warning("文案不能为空") return } - setTask({ uiStep: "step3_ready" }) - message.success("文案已确认,请在 STEP 3 点击开始生成视频") + if (!task.jobId) { + message.warning("请先完成文案生成") + return + } + setTask({ uiStep: "step3_generating", videoError: undefined }) + try { + const edited = task.generatedCopy.trim() + const originalCopy = task.job?.copy_result?.final_copy || task.generatedCopy + const job = await confirmViralCopy(task.jobId, { + // 若用户未修改则不传 edited_copy,后端用 AI 文案;若修改过则传编辑后文案 + edited_copy: edited !== originalCopy.trim() ? edited : undefined, + }) + setTask({ job }) + // 轮询继续,status=completed 时推进 step3_done + message.success("已提交视频生成,预计 1-3 分钟…") + } catch (err: unknown) { + message.error(err instanceof Error ? err.message : "提交失败") + setTask({ uiStep: "failed", videoError: err instanceof Error ? err.message : "提交失败" }) + } } - /* ── STEP 3:开始生成视频 ── */ + /* ── STEP 3:开始生成视频(重试/兜底入口,正常流程由 handleConfirmCopy 触发) ── + * 三步拆分后端下,点击「确认文案并生成视频」会直接走 confirmViralCopy 触发 TTS+渲染。 + * 此处保留「开始生成视频」按钮作为网络中断/失败后重试、或旧状态兼容入口: + * - 若 job 已在 copy_generated/image_analyzed 且文案已确认:重新调 confirm-copy + * - 其他情况:回退到旧的 /generate 全量提交(兼容旧任务/降级) */ const handleGenerateVideo = async () => { - setTask({ uiStep: "step3_generating", videoError: undefined, job: null, jobId: null }) + setTask({ uiStep: "step3_generating", videoError: undefined }) try { - const imgUrls = task.images - .filter((i) => i.ossUrl && !i.uploading) - .map((i) => i.ossUrl as string) - const payload: Parameters[0] = { - images: imgUrls, - reference_video_url: task.refVideo?.ossUrl || undefined, - style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined, - user_copy_text: task.generatedCopy || task.userCopy || undefined, - fusion_level: task.fusionLevel, - voice_id: task.refAudio?.id || undefined, - voice_source: task.refAudio?.source, - bgm_preference: task.bgm === "无BGM" ? undefined : task.bgm, - industry: task.industry || undefined, - target_customer: task.targetCustomer || undefined, - language: task.language || undefined, - viral_structure: task.viralStructure || undefined, - marketing_purpose: task.marketingPurpose || undefined, - duration: task.duration, - video_model: task.videoModel, - video_ratio: task.videoRatio, + let job + if (task.jobId && task.generatedCopy.trim()) { + // 新三步链路:基于已有 job 触发 confirm-copy + job = await confirmViralCopy(task.jobId, { + edited_copy: task.generatedCopy.trim() || undefined, + }) + } else { + // 兜底:走旧 /generate 接口(一次性跑完) + const imgUrls = task.images + .filter((i) => i.ossUrl && !i.uploading) + .map((i) => i.ossUrl as string) + const payload: Parameters[0] = { + images: imgUrls, + reference_video_url: task.refVideo?.ossUrl || undefined, + style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined, + user_copy_text: task.generatedCopy || task.userCopy || undefined, + fusion_level: task.fusionLevel, + voice_id: task.refAudio?.id || undefined, + voice_source: task.refAudio?.source, + bgm_preference: task.bgm === "无BGM" ? undefined : task.bgm, + industry: task.industry || undefined, + target_customer: task.targetCustomer || undefined, + language: task.language || undefined, + viral_structure: task.viralStructure || undefined, + marketing_purpose: task.marketingPurpose || undefined, + duration: task.duration, + video_model: task.videoModel, + video_ratio: task.videoRatio, + } + job = await generateViralVideo(payload) } - const job = await generateViralVideo(payload) setTask({ job, jobId: job.id }) message.success("视频已提交生成,预计 1-3 分钟…") } catch (err: unknown) {