From dc0782a9113ac669baf79d3d97246df4ed460e6d Mon Sep 17 00:00:00 2001 From: Coze Agent Date: Thu, 1 Oct 2026 11:01:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(viral-video):=20#2041=20=E4=B8=89=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=E7=8B=AC=E7=AB=8B=E4=BA=A4=E4=BA=92=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=9B=BE=E7=89=87/=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=96=87=E6=A1=88/=E7=94=9F=E6=88=90=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 uiStep 状态机:step1_upload→step1_analyzing→step1_done→step2_generating→ step2_copy_ready→step3_ready→step3_generating→step3_done/failed,三步完全独立 - STEP1 按钮改为「🔍 分析图片」,仅跑图片VLM分析(含mockImageAnalysis占位,后端上线analyze-only端点后替换) - STEP2 独立为「✍️ 生成文案」按钮+可编辑文案textarea+「🎬 确认文案并生成视频」按钮, 文案生成用mockGenerateCopy占位,返回完整营销文案文本(非空meta信息) - STEP3 独立为「🎬 开始生成视频」按钮,点击后才调generateViralVideo→TTS→Seedance→渲染 - 删除原「⚡ 开始分析」和黄卡合并的「确认文案并生成视频」 - STEP2/STEP3 在前置步骤未完成时禁用(opacity 0.55 + pointer-events:none + 锁提示) - 识别汇览展示商品/规格/品牌/视觉特征/品牌标签文字(新增brand/label_text字段) - 类型扩展:ViralVideoStatus 新增 image_analyzed/copy_generated;GenerateViralVideoRequest 新增 step 字段(analyze/generate_copy/generate_video);新增 CopyResult 接口 - tsc/eslint/prettier/vite build 全通过 --- apps/web/src/api/viral-video/index.ts | 62 +- apps/web/src/api/viral-video/types.ts | 63 +- .../src/pages/viral-video/ViralVideoPage.css | 215 +++++ .../src/pages/viral-video/ViralVideoPage.tsx | 848 ++++++++++-------- 4 files changed, 772 insertions(+), 416 deletions(-) diff --git a/apps/web/src/api/viral-video/index.ts b/apps/web/src/api/viral-video/index.ts index b6d3b20a9..691be3394 100644 --- a/apps/web/src/api/viral-video/index.ts +++ b/apps/web/src/api/viral-video/index.ts @@ -4,6 +4,8 @@ import type { HistoryResponse, StyleTemplate, ViralVideoJob, + ImageAnalysisResult, + CopyResult, } from "./types" /** 创建爆款视频任务 */ @@ -41,7 +43,65 @@ export function getViralStyleTemplates() { return apiClient.get("/viral-video/style-templates").then((r) => r.data) } -/** 上传参考视频后触发风格分析(返回带 style_guide 的任务详情) */ +/** 上传参考视频后触发风格分析 */ export function analyzeViralStyle(id: string) { return apiClient.post(`/viral-video/${id}/analyze-style`).then((r) => r.data) } + +/** ── 三步拆分:前端 mock 辅助函数(后端新接口上线后可替换) ── */ + +/** + * 客户端图片分析 mock(后端未提供 analyze-only 端点前的占位方案): + * 基于已上传图片生成一份示例识别汇览,让 STEP1→STEP2 交互可走通。 + * 后端上线后改为调用真实接口。 + */ +export function mockImageAnalysis(images: { name: string }[]): Promise { + return new Promise((resolve) => { + setTimeout(() => { + const products = images.slice(0, 3).map((img, i) => { + const n = img.name.replace(/\.[^.]+$/, "") + return { + name: n || `商品 ${i + 1}`, + spec: i === 0 ? "500ml/瓶" : i === 1 ? "300g/盒" : undefined, + brand: i === 0 ? "示例品牌" : undefined, + features: + i === 0 + ? "瓶身透明、蓝色标签、白色瓶盖;标签上印有品牌Logo和产品名称;光线均匀,主体居中" + : i === 1 + ? "盒装包装、主色调为米白+暖黄;正面有产品实物图;文字清晰可辨" + : "产品主体清晰、背景干净、色彩鲜艳,突出核心卖点", + label_text: i === 0 ? "包装正面印有产品名称、净含量、品牌Logo" : undefined, + image_index: i, + } + }) + resolve({ products }) + }, 1800) + }) +} + +/** + * 客户端文案生成 mock(后端未提供 generate-copy 端点前的占位方案): + * 后端上线后改为调用真实接口。 + */ +export function mockGenerateCopy(params: { + product: string + sellingPoints?: string[] + tone?: string + duration?: number + marketingPurpose?: string + industry?: string + targetCustomer?: string +}): Promise { + return new Promise((resolve) => { + setTimeout(() => { + const product = params.product || "这款产品" + const tone = params.tone || "亲切务实" + const purpose = params.marketingPurpose || "品牌种草" + resolve({ + title: `【${purpose}】${product},用过的人都说好!`, + final_copy: `你有没有发现,选对一款${params.industry || "好物"}真的能让生活省心很多?\n\n今天给大家推荐这款${product}。${tone.includes("亲切") ? "说实话," : ""}我自己用了一段时间,最直观的感受就是——好用、省心、值得回购。\n\n✅ 亮点一:品质到位,用料扎实,细节处见用心\n✅ 亮点二:使用体验舒服,日常高频场景都能打\n✅ 亮点三:性价比很能打,这个价位真的没什么可挑的\n\n如果你也在找一款靠谱的${params.industry || "日常好物"},真的建议试试${product},不会让你失望。点击左下角,直接入手!`, + suggested_copy: `你有没有发现,选对一款${params.industry || "好物"}真的能让生活省心很多?\n\n今天给大家推荐这款${product}。${tone.includes("亲切") ? "说实话," : ""}我自己用了一段时间,最直观的感受就是——好用、省心、值得回购。\n\n✅ 亮点一:品质到位,用料扎实,细节处见用心\n✅ 亮点二:使用体验舒服,日常高频场景都能打\n✅ 亮点三:性价比很能打,这个价位真的没什么可挑的\n\n如果你也在找一款靠谱的${params.industry || "日常好物"},真的建议试试${product},不会让你失望。点击左下角,直接入手!`, + }) + }, 2200) + }) +} diff --git a/apps/web/src/api/viral-video/types.ts b/apps/web/src/api/viral-video/types.ts index a04adddd1..281dd00d1 100644 --- a/apps/web/src/api/viral-video/types.ts +++ b/apps/web/src/api/viral-video/types.ts @@ -13,11 +13,18 @@ export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [ ] export type ViralVideoStatus = - "pending" | "running" | "wait_user_confirm" | "completed" | "failed" | "cancelled" + | "pending" + | "running" + | "wait_user_confirm" + | "image_analyzed" + | "copy_generated" + | "completed" + | "failed" + | "cancelled" /** * 后端流水线阶段字符串。前端不展示逐阶段进度列表,仅保留类型 - * 用于轮询时判断当前在哪个大阶段(分析中 vs 视频生成中)以选择轮询间隔/文案。 + * 用于轮询时判断当前在哪个大阶段(分析中 vs 文案生成 vs 视频生成)。 */ export type ViralVideoStage = | "image_analysis" @@ -32,38 +39,61 @@ export type ViralVideoStage = | "musetalk" | "uploading" -/** 分析类阶段(image_analysis / video_analysis / intent_parsing):属于「开始分析」阶段 */ -const ANALYSIS_STAGES = new Set([ - "image_analysis", - "video_analysis", +/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */ +const IMAGE_ANALYSIS_STAGES = new Set(["image_analysis", "video_analysis"]) +/** 文案相关阶段:属于「生成文案」按钮的范围 */ +const COPY_STAGES = new Set([ "intent_parsing", + "copy_fusion", + "storyboard", + "review", +]) +/** 视频相关阶段:属于「开始生成视频」按钮的范围 */ +const VIDEO_STAGES = new Set([ + "tts", + "bgm_select", + "rendering", + "musetalk", + "uploading", ]) +export function isImageAnalysisStage(stage: ViralVideoStage | undefined): boolean { + return !!stage && IMAGE_ANALYSIS_STAGES.has(stage) +} +export function isCopyStage(stage: ViralVideoStage | undefined): boolean { + return !!stage && COPY_STAGES.has(stage) +} +export function isVideoStage(stage: ViralVideoStage | undefined): boolean { + return !!stage && VIDEO_STAGES.has(stage) +} +/** 兼容旧调用:旧的 isAnalysisStage 视为「图片分析+文案」的所有前置阶段 */ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean { - return !!stage && ANALYSIS_STAGES.has(stage) + return isImageAnalysisStage(stage) || isCopyStage(stage) } /** 单张图片 VLM 识别出的商品信息 */ export interface ImageProductAnalysis { - /** 商品名称/标题 */ name?: string - /** 净含量/规格 */ spec?: string - /** 视觉特征描述(材质/颜色/文字标签等),可能是拼接字符串或数组 */ + brand?: string features?: string[] | string - /** 卖点 */ + label_text?: string selling_points?: string[] - /** 适用场景 */ scene?: string - /** 图片索引 */ image_index?: number } -/** 图片 VLM 分析结果(后端字段 image_analysis) */ export interface ImageAnalysisResult { products?: ImageProductAnalysis[] } +export interface CopyResult { + final_copy?: string + suggested_copy?: string + title?: string + scenes?: Array<{ shot: string; narration: string; duration?: number }> +} + export interface StyleTemplate { id: string name: string @@ -101,7 +131,7 @@ export interface ViralVideoJob { bgm_preference?: string intent_result?: IntentResult intent_text?: string - /** 图片 VLM 分析结果(后端字段 image_analysis) */ + copy_result?: CopyResult image_analysis?: ImageAnalysisResult progress_stage?: ViralVideoStage progress_percent?: number @@ -118,7 +148,6 @@ export interface ViralVideoJob { export interface GenerateViralVideoRequest { images: string[] reference_video_url?: string - /** 抖音分享链接(后端解析后下载) */ douyin_url?: string style_strength?: StyleStrength style_template_id?: string @@ -136,6 +165,8 @@ export interface GenerateViralVideoRequest { duration?: number video_model?: string video_ratio?: string + /** 三步拆分:step 控制后端执行到哪一步暂停 */ + step?: "analyze" | "generate_copy" | "generate_video" } export interface HistoryResponse { diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css index 5ff97bd18..d989b38d0 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.css +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -1151,3 +1151,218 @@ padding: 12px 18px; border-top: 1px solid #f3f4f6; } + +/* ─────────── 三步独立交互新增样式 ─────────── */ + +/* STEP 子步骤头(STEP2/STEP3) */ +.vv-step-subhead { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px 0; +} +.vv-step-subtitle { + font-size: 15px; + font-weight: 600; + color: #111827; +} +.vv-step-lock { + margin-left: auto; + font-size: 11px; + color: #9ca3af; + background: #f3f4f6; + padding: 2px 8px; + border-radius: 10px; +} +.vv-step-badge-dim { + background: #e5e7eb !important; + color: #9ca3af !important; +} + +/* 列禁用态 */ +.vv-col-disabled .vv-section { + opacity: 0.55; + pointer-events: none; +} +.vv-col-disabled .vv-section .vv-btn-primary { + background: #d1d5db !important; + cursor: not-allowed; +} + +/* Section 内小标题 */ +.vv-section-subtitle { + font-size: 13px; + font-weight: 600; + color: #374151; + margin: 12px 0 8px; + padding-left: 2px; +} +.vv-section-subtitle:first-child { + margin-top: 0; +} + +/* 文案结果区 */ +.vv-copy-box { + margin-top: 14px; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 12px; + background: #fff; +} +.vv-copy-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 28px 12px; + color: #6b7280; + font-size: 13px; + background: #f9fafb; +} +.vv-copy-head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + font-size: 13px; +} +.vv-copy-title { + font-weight: 600; + color: #111827; +} +.vv-copy-sub { + color: #7c3aed; + font-size: 12px; + margin-left: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.vv-copy-locked { + font-size: 11px; + color: #9ca3af; + background: #f3f4f6; + padding: 1px 6px; + border-radius: 4px; + margin-left: auto; +} +.vv-copy-textarea { + min-height: 160px !important; + resize: vertical; + font-size: 13px; + line-height: 1.7; + background: #fafafa; +} +.vv-copy-textarea:disabled { + background: #f9fafb; + color: #374151; + cursor: default; +} + +/* 块级按钮 */ + +/* ─────────── 三步独立交互新增样式 ─────────── */ + +/* STEP 子步骤头(STEP2/STEP3) */ +.vv-step-subhead { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px 0; +} +.vv-step-subtitle { + font-size: 15px; + font-weight: 600; + color: #111827; +} +.vv-step-lock { + margin-left: auto; + font-size: 11px; + color: #9ca3af; + background: #f3f4f6; + padding: 2px 8px; + border-radius: 10px; +} +.vv-step-badge-dim { + background: #e5e7eb !important; + color: #9ca3af !important; +} + +/* 列禁用态 */ +.vv-col-disabled .vv-section { + opacity: 0.55; +} +.vv-col-disabled .vv-section .vv-btn, +.vv-col-disabled .vv-section button, +.vv-col-disabled .vv-section input, +.vv-col-disabled .vv-section textarea, +.vv-col-disabled .vv-section .ant-select { + pointer-events: none; +} + +/* Section 内小标题 */ +.vv-section-subtitle { + font-size: 13px; + font-weight: 600; + color: #374151; + margin: 12px 0 8px; + padding-left: 2px; +} + +/* 文案结果区 */ +.vv-copy-box { + margin-top: 14px; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 12px; + background: #fff; +} +.vv-copy-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 28px 12px; + color: #6b7280; + font-size: 13px; + background: #f9fafb; +} +.vv-copy-head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + font-size: 13px; +} +.vv-copy-title { + font-weight: 600; + color: #111827; +} +.vv-copy-sub { + color: #7c3aed; + font-size: 12px; + margin-left: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.vv-copy-locked { + font-size: 11px; + color: #9ca3af; + background: #f3f4f6; + padding: 1px 6px; + border-radius: 4px; + margin-left: auto; +} +.vv-copy-textarea { + min-height: 160px !important; + resize: vertical; + font-size: 13px; + line-height: 1.7; + background: #fafafa; +} +.vv-copy-textarea:disabled { + background: #f9fafb; + color: #374151; + cursor: default; +} diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index 96f156267..f2382ee6b 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -11,8 +11,7 @@ import { SoundOutlined, ReloadOutlined, WarningFilled, - BulbFilled, - ThunderboltOutlined, + SearchOutlined, ArrowUpOutlined, ArrowDownOutlined, FileTextOutlined, @@ -20,25 +19,46 @@ import { AudioMutedOutlined, LinkOutlined, LoadingOutlined, + CheckCircleFilled, + EditOutlined, } from "@ant-design/icons" import { Select, Input, message } from "antd" import { uploadAssetDirect, getAssetLibraries, getAssetsByKind, type AssetItem } from "@/api/assets" import { FUSION_LEVELS, STYLE_STRENGTHS, - isAnalysisStage, + isVideoStage, type FusionLevel, type StyleStrength, type ViralVideoJob, type ImageProductAnalysis, + type ImageAnalysisResult, + type CopyResult, } from "@/api/viral-video/types" -import { generateViralVideo, confirmViralVideoIntent, retryViralVideo } from "@/api/viral-video" +import { + generateViralVideo, + retryViralVideo, + mockImageAnalysis, + mockGenerateCopy, +} from "@/api/viral-video" import { useViralVideoPolling } from "./hooks/useViralVideoPolling" import CloneModal from "@/components/voice/CloneModal" import AssetPickerModal from "./components/AssetPickerModal" import "./ViralVideoPage.css" -/* ─────────── 常量与类型 ─────────── */ +/* ─────────── UI 阶段(三步骤独立交互) ─────────── */ +type UIStep = + | "step1_upload" // 初始:上传素材 + | "step1_analyzing" // 图片分析中 + | "step1_done" // 图片分析完成 + | "step2_generating" // 文案生成中 + | "step2_copy_ready" // 文案已生成,等待确认 + | "step3_ready" // 文案已确认,等待点击开始生成视频 + | "step3_generating" // 视频生成中 + | "step3_done" // 视频完成 + | "failed" // 失败 + +/* ─────────── 类型 ─────────── */ type UploadedImage = { localId: string @@ -51,6 +71,17 @@ type UploadedImage = { file?: File } +type RefVideo = { + source: "local" | "douyin" + url: string + name: string + uploading: boolean + pct: number + ossUrl?: string + file?: File + douyinUrl?: string +} | null + type RefAudio = { source: "preset" | "library" | "upload" | "clone" id?: string @@ -63,21 +94,13 @@ type VoicePanel = "preset" | "upload" | "library" | "record" | "douyin" | null type TabTask = { id: string title: string - // 编辑态 + // 素材 images: UploadedImage[] - refVideo: { - source: "local" | "douyin" - url: string // blob: 预览或 oss URL - name: string - uploading: boolean - pct: number - ossUrl?: string - file?: File - douyinUrl?: string - } | null + refVideo: RefVideo douyinInput: string voicePanel: VoicePanel refAudio: RefAudio + // STEP2 参数 fusionLevel: FusionLevel styleStrength: StyleStrength userCopy: string @@ -88,19 +111,26 @@ type TabTask = { marketingPurpose: string duration: number bgm: string + // STEP3 设置 videoRatio: string videoModel: string - intentEditText: string + // UI 状态 + uiStep: UIStep + imageAnalysis: ImageAnalysisResult | null + generatedCopy: string // 文案结果(可编辑) + generatedTitle: string voiceTipsOpen: boolean - // 运行态 + copyError?: string + videoError?: string + // 运行态(后端 job) job: ViralVideoJob | null jobId: string | null playingVoiceId: string | null audioInst: HTMLAudioElement | null - analyzing: boolean - generating: boolean } +/* ─────────── 常量 ─────────── */ + const INDUSTRIES = [ "美妆个护", "服饰鞋包", @@ -158,14 +188,15 @@ const emptyTask = (id: string, title: string): TabTask => ({ bgm: BGMS[0], videoRatio: "9:16", videoModel: "seedance-2.5", - intentEditText: "", + uiStep: "step1_upload", + imageAnalysis: null, + generatedCopy: "", + generatedTitle: "", voiceTipsOpen: false, job: null, jobId: null, playingVoiceId: null, audioInst: null, - analyzing: false, - generating: false, }) /* ─────────── 主页面 ─────────── */ @@ -183,7 +214,11 @@ const ViralVideoPage: React.FC = () => { open: boolean kind: "image" | "video" | "voice" multiple: boolean - }>({ open: false, kind: "image", multiple: false }) + }>({ + open: false, + kind: "image", + multiple: false, + }) const imgInputRef = useRef(null) const videoInputRef = useRef(null) @@ -202,7 +237,7 @@ const ViralVideoPage: React.FC = () => { [activeId], ) - /* ── 加载音色素材 ── */ + /* ── 加载音色 ── */ useEffect(() => { getAssetsByKind("voice", { limit: 50 }) .then((list) => @@ -216,7 +251,6 @@ const ViralVideoPage: React.FC = () => { ), ) .catch(() => {}) - // 预置音色:从 /api/tts/presets 拉(失败则用占位) fetch("/api/v1/tts/presets", { credentials: "include" }) .then((r) => (r.ok ? r.json() : [])) .then((list: { voice_id?: string; id?: string; name?: string; display_name?: string }[]) => { @@ -230,7 +264,6 @@ const ViralVideoPage: React.FC = () => { ) }) .catch(() => { - // 兜底:展示一些常见音色名 setPresetVoices([ { id: "zh-CN-XiaoxiaoNeural", name: "晓晓", desc: "温柔女声" }, { id: "zh-CN-YunxiNeural", name: "云希", desc: "活力男声" }, @@ -240,33 +273,22 @@ const ViralVideoPage: React.FC = () => { }) }, []) - /* ── 轮询 ── */ + /* ── 轮询(job 存在时才轮询,在视频生成阶段使用) ── */ const onPollUpdate = useCallback( (job: ViralVideoJob) => { setTask((t) => { - const prevStatus = t.job?.status - const newIntentEdit = - prevStatus !== "wait_user_confirm" && job.status === "wait_user_confirm" - ? job.intent_result?.suggested_copy || t.userCopy - : t.intentEditText + 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" + } + const err = job.error_message || job.error_msg return { ...t, job, - intentEditText: newIntentEdit, - analyzing: - job.status === "wait_user_confirm" || - job.status === "completed" || - job.status === "failed" || - job.status === "cancelled" - ? false - : t.analyzing, - generating: - job.status === "completed" || - job.status === "failed" || - job.status === "wait_user_confirm" || - job.status === "cancelled" - ? false - : t.generating, + uiStep: nextStep, + videoError: nextStep === "failed" ? err || t.videoError : undefined, } }) }, @@ -274,7 +296,7 @@ const ViralVideoPage: React.FC = () => { ) useViralVideoPolling(task.jobId, onPollUpdate) - /* ── 上传文件到 OSS ── */ + /* ── OSS 上传 ── */ const uploadFile = useCallback( async (file: File, kind: "image" | "video" | "voice", onProgress?: (pct: number) => void) => { const libs = await getAssetLibraries() @@ -283,14 +305,16 @@ const ViralVideoPage: React.FC = () => { const res = await uploadAssetDirect({ file, library_id: lib.id, - onProgress: (p: number) => onProgress?.(Math.round(p)), + onProgress: (p) => onProgress?.(Math.round(p)), }) return res.url }, [], ) - /* ── 上传图片(本地+素材库选择) ── */ + /* ── 图片 ── */ + const canEditAssets = task.uiStep === "step1_upload" || task.uiStep === "failed" + const appendImagesFromFiles = useCallback( async (files: FileList | File[]) => { const remaining = 9 - task.images.length @@ -363,7 +387,16 @@ const ViralVideoPage: React.FC = () => { t.images.forEach((im) => { if (im.url?.startsWith("blob:")) URL.revokeObjectURL(im.url) }) - return { ...t, images: [] } + return { + ...t, + images: [], + imageAnalysis: null, + uiStep: "step1_upload", + generatedCopy: "", + generatedTitle: "", + job: null, + jobId: null, + } }) } @@ -371,7 +404,21 @@ const ViralVideoPage: React.FC = () => { setTask((t) => { const im = t.images.find((i) => i.localId === localId) if (im?.url?.startsWith("blob:")) URL.revokeObjectURL(im.url) - return { ...t, images: t.images.filter((i) => i.localId !== localId) } + // 如果删除后回到0张,重置到step1_upload + const newImgs = t.images.filter((i) => i.localId !== localId) + return { + ...t, + images: newImgs, + imageAnalysis: newImgs.length === 0 ? null : t.imageAnalysis, + uiStep: + newImgs.length === 0 + ? "step1_upload" + : t.uiStep === "step1_analyzing" || t.uiStep === "step1_done" + ? "step1_upload" + : t.uiStep, + generatedCopy: newImgs.length === 0 ? "" : t.generatedCopy, + generatedTitle: newImgs.length === 0 ? "" : t.generatedTitle, + } }) } @@ -385,7 +432,7 @@ const ViralVideoPage: React.FC = () => { }) } - /* ── 参考视频:本地 / 抖音链接 ── */ + /* ── 参考视频 ── */ const handleLocalRefVideo = useCallback( async (file: File) => { if (file.size > 200 * 1024 * 1024) { @@ -428,10 +475,9 @@ const ViralVideoPage: React.FC = () => { return } if (!/(douyin\.com|iesdouyin\.com)/.test(url)) { - message.warning("请输入有效的抖音链接(v.douyin.com 或 www.douyin.com)") + message.warning("请输入有效的抖音链接") return } - // 后端解析接口尚未开放:先提示,保留 URL 到 payload 中作为 douyin_url 字段(后端若不识别会忽略) message.info("抖音链接识别功能即将上线,当前请使用本地上传参考视频") setTask({ douyinInput: "" }) } @@ -451,14 +497,9 @@ const ViralVideoPage: React.FC = () => { return } const localUrl = URL.createObjectURL(file) - const name = file.name - // 上传到 voice 素材库 try { const url = await uploadFile(file, "voice") - setTask({ - refAudio: { source: "upload", name, url, id: url }, - voicePanel: null, - }) + setTask({ refAudio: { source: "upload", name: file.name, url, id: url }, voicePanel: null }) message.success("音频已上传") getAssetsByKind("voice", { limit: 50 }) .then((list) => @@ -500,21 +541,13 @@ const ViralVideoPage: React.FC = () => { const pickVoiceAsset = (asset: AssetItem) => { setTask({ - refAudio: { - source: "library", - id: asset.id, - name: asset.name, - url: asset.file_url || "", - }, + refAudio: { source: "library", id: asset.id, name: asset.name, url: asset.file_url || "" }, voicePanel: null, }) } const pickPresetVoice = (v: { id: string; name: string }) => { - setTask({ - refAudio: { source: "preset", id: v.id, name: v.name }, - voicePanel: null, - }) + setTask({ refAudio: { source: "preset", id: v.id, name: v.name }, voicePanel: null }) } /* ── Tab 管理 ── */ @@ -539,12 +572,10 @@ const ViralVideoPage: React.FC = () => { setTasks((ts) => ts.map((t) => (t.id === activeId ? emptyTask(t.id, t.title) : t))) } - /* ── STEP1:开始分析 ── */ - const handleAnalyze = async () => { - const imgUrls = task.images - .filter((i) => i.ossUrl && !i.uploading) - .map((i) => i.ossUrl as string) - if (imgUrls.length === 0) { + /* ── STEP 1:分析图片 ── */ + const handleAnalyzeImages = async () => { + const readyImgs = task.images.filter((i) => i.ossUrl && !i.uploading) + if (readyImgs.length === 0) { message.warning("请至少上传 1 张产品图") return } @@ -556,16 +587,71 @@ const ViralVideoPage: React.FC = () => { message.warning("参考视频上传中,请稍候") return } - setTask({ analyzing: true }) + setTask({ uiStep: "step1_analyzing" }) try { - const voice_id = task.refAudio?.id || "" + // TODO: 后端上线 analyze-only 端点后替换为真实 API(传 step:"analyze") + const result = await mockImageAnalysis(task.images) + setTask({ imageAnalysis: result, uiStep: "step1_done" }) + message.success("图片分析完成,请在 STEP 2 配置文案参数") + } catch (err: unknown) { + message.error(err instanceof Error ? err.message : "分析失败") + setTask({ uiStep: "failed", copyError: err instanceof Error ? err.message : "分析失败" }) + } + } + + /* ── STEP 2:生成文案 ── */ + const handleGenerateCopy = async () => { + 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: "亲切务实", + duration: task.duration, + marketingPurpose: task.marketingPurpose, + industry: task.industry, + targetCustomer: task.targetCustomer, + }) + setTask({ + uiStep: "step2_copy_ready", + generatedCopy: result.final_copy || result.suggested_copy || "", + generatedTitle: result.title || "", + }) + message.success("文案已生成,请查看并可在下方编辑") + } 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 = () => { + if (!task.generatedCopy.trim()) { + message.warning("文案不能为空") + return + } + setTask({ uiStep: "step3_ready" }) + message.success("文案已确认,请在 STEP 3 点击开始生成视频") + } + + /* ── STEP 3:开始生成视频 ── */ + const handleGenerateVideo = async () => { + setTask({ uiStep: "step3_generating", videoError: undefined, job: null, jobId: null }) + 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.fusionLevel !== "ai_full" ? task.userCopy : undefined, + user_copy_text: task.generatedCopy || task.userCopy || undefined, fusion_level: task.fusionLevel, - voice_id: voice_id || undefined, + 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, @@ -577,36 +663,30 @@ const ViralVideoPage: React.FC = () => { video_ratio: task.videoRatio, } const job = await generateViralVideo(payload) - setTask({ job, jobId: job.id, analyzing: false }) - message.success("开始分析,稍候会给出 AI 理解的文案…") + setTask({ job, jobId: job.id }) + message.success("视频已提交生成,预计 1-3 分钟…") } catch (err: unknown) { message.error(err instanceof Error ? err.message : "提交失败") - setTask({ analyzing: false }) - } - } - - /* ── STEP2:确认并生成视频 ── */ - const handleConfirmAndGenerate = async () => { - if (!task.jobId) return - setTask({ generating: true }) - try { - const j = await confirmViralVideoIntent(task.jobId, { confirmed_copy: task.intentEditText }) - setTask({ job: j }) - message.success("文案已确认,开始生成视频…") - } catch (err: unknown) { - message.error(err instanceof Error ? err.message : "确认失败") - setTask({ generating: false }) + setTask({ uiStep: "failed", videoError: err instanceof Error ? err.message : "提交失败" }) } } const handleRetry = async () => { - if (!task.jobId) return - try { - const j = await retryViralVideo(task.jobId) - setTask({ job: j }) - message.success("已重试") - } catch (err: unknown) { - message.error(err instanceof Error ? err.message : "重试失败") + if (task.jobId) { + try { + const j = await retryViralVideo(task.jobId) + setTask({ job: j, uiStep: "step3_generating", videoError: undefined }) + message.success("已重试") + } catch (err: unknown) { + message.error(err instanceof Error ? err.message : "重试失败") + } + } else { + // 无 jobId 则重置到可重试的上一步 + setTask({ + uiStep: task.generatedCopy ? "step2_copy_ready" : "step1_done", + videoError: undefined, + copyError: undefined, + }) } } @@ -628,29 +708,32 @@ const ViralVideoPage: React.FC = () => { useEffect(() => () => audioRef.current?.pause(), []) /* ── 派生状态 ── */ - 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 || isAnalysisStage(task.job.progress_stage))) - const isGenerating = - task.generating || (task.job?.status === "running" && !isAnalysisStage(task.job.progress_stage)) - const isDone = task.job?.status === "completed" - const isFailed = task.job?.status === "failed" - const outputUrl = task.job?.output_url || task.job?.result_video_url - const errMsg = task.job?.error_message || task.job?.error_msg + const errMsg = task.videoError || task.job?.error_message || task.job?.error_msg + + const canEditStep2 = + task.uiStep !== "step2_generating" && + task.uiStep !== "step3_generating" && + task.uiStep !== "step3_done" + const step2Enabled = + task.uiStep === "step1_done" || + task.uiStep === "step2_generating" || + task.uiStep === "step2_copy_ready" || + task.uiStep === "step3_ready" || + task.uiStep === "step3_generating" || + task.uiStep === "step3_done" || + task.uiStep === "failed" + const step3Enabled = + task.uiStep === "step3_ready" || + task.uiStep === "step3_generating" || + task.uiStep === "step3_done" || + task.uiStep === "failed" /* ── 识别描述汇览渲染 ── */ const renderRecognition = () => { - const analysis = task.job?.image_analysis const products: ImageProductAnalysis[] = - (analysis?.products as ImageProductAnalysis[] | undefined) || [] - if (isAnalyzing && products.length === 0) { + (task.imageAnalysis?.products as ImageProductAnalysis[] | undefined) || [] + if (task.uiStep === "step1_analyzing") { return (
@@ -661,18 +744,16 @@ const ViralVideoPage: React.FC = () => {
) } - if (!analysis || products.length === 0) return null - + if (products.length === 0) return null const featureText = (f: string[] | string | undefined) => { if (!f) return "" if (Array.isArray(f)) return f.join(";") return f } - return (
- + 识别描述汇览
{products.map((p, i) => ( @@ -681,7 +762,8 @@ const ViralVideoPage: React.FC = () => {
商品: {p.name} - {p.spec && ,{p.spec}} + {p.spec && ({p.spec})} + {p.brand && · {p.brand}}
)} {featureText(p.features) && ( @@ -690,12 +772,61 @@ const ViralVideoPage: React.FC = () => { {featureText(p.features)}
)} + {p.label_text && ( +
+ 品牌/标签文字: + {p.label_text} +
+ )}
))} ) } + /* ── 文案结果区 ── */ + const renderCopyResult = () => { + if (task.uiStep === "step2_generating") { + return ( +
+ + AI 正在创作营销文案… +
+ ) + } + if ( + task.uiStep === "step2_copy_ready" || + task.uiStep === "step3_ready" || + task.uiStep === "step3_generating" || + task.uiStep === "step3_done" + ) { + const locked = task.uiStep !== "step2_copy_ready" + return ( +
+
+ + AI 生成文案 + {task.generatedTitle && {task.generatedTitle}} + {locked && (已锁定)} +
+