From ad74b532f78868860cac439f0feffe87212a6887 Mon Sep 17 00:00:00 2001 From: Coze Agent Date: Thu, 1 Oct 2026 12:57:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(viral-video):=20STEP2=20=E5=88=86=E9=95=9C?= =?UTF-8?q?=E8=84=9A=E6=9C=ACUI=20-=20=E7=BC=96=E5=AF=BC=E5=88=86=E9=95=9C?= =?UTF-8?q?=E5=8D=A1/=E7=BA=A6=E6=9D=9F/=E8=B4=9F=E9=9D=A2=E6=8F=90?= =?UTF-8?q?=E7=A4=BA/=E6=8A=98=E5=8F=A0=E5=8F=A3=E6=92=AD=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/viral-video/ViralVideoPage.css | 262 +++++++++ .../src/pages/viral-video/ViralVideoPage.tsx | 528 ++++++++++++++++-- 2 files changed, 733 insertions(+), 57 deletions(-) diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css index 3c600dc1f..914a2ec4a 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.css +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -1701,3 +1701,265 @@ grid-template-columns: 1fr; } } + +/* ── 分镜脚本(Storyboard)样式 ── */ +.vv-storyboard { + display: flex; + flex-direction: column; + gap: 14px; +} + +.vv-copy-tag { + display: inline-flex; + align-items: center; + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + margin-left: 8px; +} +.vv-copy-tag-preview { + background: #fef3c7; + color: #92400e; +} + +/* 总览卡片 */ +.vv-sb-overview { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 12px; + padding: 12px; + background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%); + border-radius: 10px; + border: 1px solid #e9e4ff; +} +.vv-sb-ov-field label { + display: block; + font-size: 11px; + color: #6b7280; + margin-bottom: 4px; +} +.vv-sb-input { + height: 32px; + font-size: 13px; +} + +/* 分块标题 */ +.vv-sb-section { + display: flex; + flex-direction: column; + gap: 8px; +} +.vv-sb-section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: #374151; +} + +/* 镜头卡片 */ +.vv-sb-shots { + display: flex; + flex-direction: column; + gap: 10px; +} +.vv-sb-shot { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 12px; +} +.vv-sb-shot-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} +.vv-sb-shot-num { + background: #7c3aed; + color: #fff; + font-size: 12px; + font-weight: 600; + padding: 2px 10px; + border-radius: 10px; +} +.vv-sb-time { + height: 28px; + border: 1px solid #e5e7eb; + background: #fff; + border-radius: 6px; + padding: 0 10px; + font-size: 12px; + color: #6b7280; + width: 100px; + outline: none; +} +.vv-sb-time:focus { + border-color: #7c3aed; +} +.vv-sb-shot-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 14px; +} +.vv-sb-cell { + display: flex; + flex-direction: column; + gap: 4px; +} +.vv-sb-cell-wide { + grid-column: 1 / -1; +} +.vv-sb-cell label { + font-size: 11px; + color: #6b7280; + font-weight: 500; +} +.vv-sb-cell-ta { + min-height: 54px !important; + font-size: 12.5px !important; + padding: 7px 10px !important; + line-height: 1.55 !important; + resize: vertical; +} +.vv-sb-ref-box { + width: 80px; + height: 54px; + border-radius: 8px; + overflow: hidden; + background: #fff; + border: 1px dashed #d1d5db; + display: flex; + align-items: center; + justify-content: center; +} +.vv-sb-ref-img { + width: 100%; + height: 100%; + object-fit: cover; +} +.vv-sb-ref-empty { + font-size: 11px; + color: #9ca3af; +} + +/* 标签(硬性约束/负面提示词) */ +.vv-sb-taglist { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.vv-sb-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 4px 2px 10px; + border-radius: 14px; + font-size: 12px; +} +.vv-sb-tag-hard { + background: #f3f0ff; + color: #6d28d9; + border: 1px solid #ddd6fe; +} +.vv-sb-tag-neg { + background: #fef2f2; + color: #b91c1c; + border: 1px solid #fecaca; +} +.vv-sb-tag-input { + background: transparent; + border: none; + outline: none; + font-size: 12px; + color: inherit; + min-width: 80px; + padding: 2px 0; +} +.vv-sb-tag-hard .vv-sb-tag-input { + color: #6d28d9; +} +.vv-sb-tag-neg .vv-sb-tag-input { + color: #b91c1c; +} +.vv-sb-tag-del { + width: 18px; + height: 18px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.08); + color: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1; +} +.vv-sb-tag-del:hover { + background: rgba(0, 0, 0, 0.18); +} +.vv-sb-tag-add { + border: 1px dashed #d1d5db; + background: transparent; + color: #6b7280; + font-size: 12px; + padding: 3px 10px; + border-radius: 14px; + cursor: pointer; +} +.vv-sb-tag-add:hover { + border-color: #7c3aed; + color: #7c3aed; +} + +/* 口播稿折叠 */ +.vv-sb-collapse { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 10px 14px; + cursor: pointer; + font-size: 13px; + color: #374151; + font-weight: 500; + transition: + border-color 0.15s, + background 0.15s; +} +.vv-sb-collapse:hover { + border-color: #7c3aed; + background: #faf8ff; +} +.vv-sb-caret { + transition: transform 0.2s; + color: #9ca3af; + font-size: 12px; +} +.vv-sb-caret.open { + transform: rotate(180deg); + color: #7c3aed; +} +.vv-sb-vo { + min-height: 100px !important; + font-size: 13px !important; + line-height: 1.7 !important; +} + +@media (max-width: 640px) { + .vv-sb-overview { + grid-template-columns: 1fr; + } + .vv-sb-shot-grid { + grid-template-columns: 1fr; + } + .vv-sb-cell-wide { + grid-column: auto; + } +} diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index adba29306..57c4f8b4e 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -9,6 +9,8 @@ import { CaretRightOutlined, VideoCameraOutlined, SoundOutlined, + BulbOutlined, + ThunderboltOutlined, ReloadOutlined, WarningFilled, SearchOutlined, @@ -120,8 +122,8 @@ type TabTask = { // UI 状态 uiStep: UIStep imageAnalysis: ImageAnalysisResult | null - generatedCopy: string // 文案结果(可编辑) - generatedTitle: string + // ── 分镜脚本(新结构) ── + storyboard: Storyboard | null voiceTipsOpen: boolean copyError?: string videoError?: string @@ -166,6 +168,141 @@ const MODELS = [ const VOICE_TIPS = "支持 MP3/WAV/M4A/AAC/OGG 格式,最大 10MB,时长 ≤30 秒。建议清晰人声、无背景音乐、环境安静;录音请保持距麦克风 15-20cm,音量适中。" +// ── 分镜脚本数据模型(新后端 copy_result 结构,前端先 mock 展示) ── +interface StoryboardShot { + time_range: string + shot_type_angle_movement: string + scene_and_dialogue: string + action_details: string + audio_bgm: string + transition: string + reference_image_index?: number +} +interface Storyboard { + overview: { theme: string; total_duration: string; aspect_ratio: string } + scene_and_lighting: string + shots: StoryboardShot[] + hard_constraints: string[] + negative_prompts: string[] + voiceover_script: string +} + +/** 兼容旧 copy_result(final_copy/title/scenes)→ 新 Storyboard 结构 */ +function copyResultToStoryboard(cr: any): Storyboard | null { + if (!cr) return null + // 新结构:含 shots/overview 字段直接用 + if (Array.isArray(cr.shots) && cr.overview) { + return { + overview: { + theme: cr.overview.theme || cr.title || "", + total_duration: cr.overview.total_duration || "", + aspect_ratio: cr.overview.aspect_ratio || "9:16", + }, + scene_and_lighting: cr.scene_and_lighting || "", + shots: (cr.shots as any[]).map((s) => ({ + time_range: s.time_range || "", + shot_type_angle_movement: s.shot_type_angle_movement || "", + scene_and_dialogue: s.scene_and_dialogue || s.dialogue || "", + action_details: s.action_details || "", + audio_bgm: s.audio_bgm || "", + transition: s.transition || "", + reference_image_index: + typeof s.reference_image_index === "number" ? s.reference_image_index : undefined, + })), + hard_constraints: Array.isArray(cr.hard_constraints) ? cr.hard_constraints : [], + negative_prompts: Array.isArray(cr.negative_prompts) ? cr.negative_prompts : [], + voiceover_script: cr.voiceover_script || cr.final_copy || cr.suggested_copy || "", + } + } + // 兜底:旧结构转简单分镜 + const finalCopy = cr.final_copy || cr.suggested_copy || "" + const title = cr.title || "" + return { + overview: { theme: title || "AI 分镜脚本", total_duration: "", aspect_ratio: "9:16" }, + scene_and_lighting: "", + shots: + cr.scenes && cr.scenes.length + ? cr.scenes.map((sc: any, i: number) => ({ + time_range: sc.duration + ? `${i * sc.duration}-${(i + 1) * sc.duration}s` + : `镜头${i + 1}`, + shot_type_angle_movement: sc.shot || "", + scene_and_dialogue: sc.narration || "", + action_details: "", + audio_bgm: "", + transition: "", + })) + : finalCopy + ? [ + { + time_range: "0-15s", + shot_type_angle_movement: "", + scene_and_dialogue: finalCopy, + action_details: "", + audio_bgm: "", + transition: "", + }, + ] + : [], + hard_constraints: [], + negative_prompts: [], + voiceover_script: finalCopy, + } +} + +/** Mock 分镜脚本(后端 API 未就绪时的占位展示) */ +const MOCK_STORYBOARD: Storyboard = { + overview: { theme: "实木餐桌场景化种草", total_duration: "15s", aspect_ratio: "9:16" }, + scene_and_lighting: + "暖黄调家居场景,午后阳光从纱帘斜射入餐厅,桌上摆满家常菜,整体营造温馨治愈、高质感的生活氛围。", + shots: [ + { + time_range: "0-3s", + shot_type_angle_movement: "特写/俯拍/慢推", + scene_and_dialogue: "(盘子掀开冒热气)还在为餐桌选不到好桌子发愁?", + action_details: "双手掀开白色瓷盘盖,蒸汽缓缓升腾,桌上红烧排骨特写", + audio_bgm: "轻快钢琴+盘子掀开的清脆音效", + transition: "蒸汽模糊转场", + reference_image_index: 0, + }, + { + time_range: "3-8s", + shot_type_angle_movement: "中景/平视/稳定", + scene_and_dialogue: "这张北美黑胡桃木餐桌,一家人坐下来吃饭刚刚好。", + action_details: "镜头切到一家人围坐用餐,妈妈夹菜给孩子,爸爸微笑", + audio_bgm: "温馨BGM持续,轻人声环境音", + transition: "切", + reference_image_index: 1, + }, + { + time_range: "8-12s", + shot_type_angle_movement: "特写/45度/滑轨横移", + scene_and_dialogue: "全实木、无贴皮,纹理好看又耐刮。", + action_details: "手指轻抚桌面木纹,杯子放在桌上不晃动,展示承重细节", + audio_bgm: "手指划木面的摩擦声", + transition: "切", + reference_image_index: 2, + }, + { + time_range: "12-15s", + shot_type_angle_movement: "全景/微仰/拉远", + scene_and_dialogue: "点小黄车,给家里添一张好桌子。", + action_details: "产品全景+CTA字幕弹出,右下角购物车图标放大", + audio_bgm: "叮咚提示音+BGM收尾", + transition: "淡出", + reference_image_index: 0, + }, + ], + hard_constraints: [ + "画面暖调温馨,不可出现冷光", + "人物必须是一家人(父母+孩子),不能单独人物", + "餐桌材质必须显示为实木", + ], + negative_prompts: ["冷色调", "模糊", "变形", "水印文字", "卡通风格", "空无一人"], + voiceover_script: + "还在为餐桌选不到好桌子发愁?这张北美黑胡桃木餐桌,一家人坐下来吃饭刚刚好。全实木、无贴皮,纹理好看又耐刮。点小黄车,给家里添一张好桌子。", +} + const fmtSize = (bytes: number) => { if (bytes < 1024) return `${bytes}B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` @@ -194,8 +331,7 @@ const emptyTask = (id: string, title: string): TabTask => ({ videoModel: "seedance-2.5", uiStep: "step1_upload", imageAnalysis: null, - generatedCopy: "", - generatedTitle: "", + storyboard: null, voiceTipsOpen: false, job: null, jobId: null, @@ -208,6 +344,7 @@ const ViralVideoPage: React.FC = () => { const [tasks, setTasks] = useState(() => [emptyTask("t1", "生成 1")]) const [activeId, setActiveId] = useState("t1") const [cloneModalOpen, setCloneModalOpen] = useState(false) + const [voOpen, setVoOpen] = useState(false) // 口播稿折叠 const [voiceAssets, setVoiceAssets] = useState< { id: string; name: string; url: string; desc?: string }[] >([]) @@ -287,9 +424,10 @@ const ViralVideoPage: React.FC = () => { // 同步服务端最新的分析/文案结果(若已返回) 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 + // 新结构分镜 → storyboard;老结构通过 helper 兜底转换 + const nextStoryboard = nextCopy + ? copyResultToStoryboard(nextCopy) || t.storyboard + : t.storyboard switch (job.status) { case "image_analyzed": @@ -327,8 +465,7 @@ const ViralVideoPage: React.FC = () => { job, uiStep: nextStep, imageAnalysis: nextImageAnalysis, - generatedCopy: nextGeneratedCopy, - generatedTitle: nextGeneratedTitle, + storyboard: nextStoryboard, copyError: nextStep === "failed" && isCopyStage(job.progress_stage) ? copyError : copyError, videoError: @@ -436,8 +573,7 @@ const ViralVideoPage: React.FC = () => { images: [], imageAnalysis: null, uiStep: "step1_upload", - generatedCopy: "", - generatedTitle: "", + storyboard: null, job: null, jobId: null, } @@ -454,14 +590,7 @@ const ViralVideoPage: React.FC = () => { ...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, + storyboard: newImgs.length === 0 ? null : t.storyboard, } }) } @@ -682,10 +811,13 @@ const ViralVideoPage: React.FC = () => { setTask({ uiStep: "step3_generating", videoError: undefined }) try { let job - if (task.jobId && task.generatedCopy.trim()) { + if (task.jobId && task.storyboard && task.storyboard.voiceover_script.trim()) { // 新三步链路:基于已有 job 触发 confirm-copy - const edited = task.generatedCopy.trim() - const originalCopy = task.job?.copy_result?.final_copy || "" + const edited = task.storyboard.voiceover_script.trim() + const originalCopy = + (task.job?.copy_result as any)?.voiceover_script || + task.job?.copy_result?.final_copy || + "" job = await confirmViralCopy(task.jobId, { edited_copy: edited && edited !== originalCopy.trim() ? edited : undefined, }) @@ -698,7 +830,7 @@ const ViralVideoPage: React.FC = () => { 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, + user_copy_text: task.storyboard?.voiceover_script || task.userCopy || undefined, fusion_level: task.fusionLevel, voice_id: task.refAudio?.id || undefined, voice_source: task.refAudio?.source, @@ -733,7 +865,7 @@ const ViralVideoPage: React.FC = () => { } else { // 无 jobId 则重置到可重试的上一步 setTask({ - uiStep: task.generatedCopy ? "step2_copy_ready" : "step1_done", + uiStep: task.storyboard ? "step2_copy_ready" : "step1_done", videoError: undefined, copyError: undefined, }) @@ -835,51 +967,333 @@ const ViralVideoPage: React.FC = () => { ) } - /* ── 文案结果区 ── */ + /* ── 分镜脚本结果区(编导分镜卡片 UI) ── */ + const updateShot = (idx: number, patch: Partial) => { + if (!task.storyboard) return + const shots = task.storyboard.shots.map((s, i) => (i === idx ? { ...s, ...patch } : s)) + setTask({ storyboard: { ...task.storyboard, shots } }) + } + const updateStoryboard = (patch: Partial) => { + if (!task.storyboard) return + setTask({ storyboard: { ...task.storyboard, ...patch } }) + } + const updateOverview = (patch: Partial) => { + if (!task.storyboard) return + setTask({ + storyboard: { ...task.storyboard, overview: { ...task.storyboard.overview, ...patch } }, + }) + } + const updateConstraints = ( + list: "hard_constraints" | "negative_prompts", + idx: number, + v: string, + ) => { + if (!task.storyboard) return + const arr = [...task.storyboard[list]] + arr[idx] = v + setTask({ storyboard: { ...task.storyboard, [list]: arr } }) + } + const addConstraint = (list: "hard_constraints" | "negative_prompts") => { + if (!task.storyboard) return + setTask({ storyboard: { ...task.storyboard, [list]: [...task.storyboard[list], ""] } }) + } + const removeConstraint = (list: "hard_constraints" | "negative_prompts", idx: number) => { + if (!task.storyboard) return + const arr = task.storyboard[list].filter((_, i) => i !== idx) + setTask({ storyboard: { ...task.storyboard, [list]: arr } }) + } + const renderCopyResult = () => { if (task.uiStep === "step2_generating") { return (
- AI 正在创作营销文案… + AI 正在创作视频分镜脚本… +
+ ) + } + // 后端新结构尚未就绪时,用 MOCK_STORYBOARD 做预览展示;新结构到了之后优先用真实数据 + const sb: Storyboard | null = (() => { + if (!task.storyboard) return null + const isNewStructure = + task.storyboard.shots.length >= 2 && + !!task.storyboard.scene_and_lighting && + task.storyboard.overview.total_duration.length > 0 + if (isNewStructure) return task.storyboard + // 后端还在旧结构:兜底用 mock 展示分镜卡片效果 + return MOCK_STORYBOARD + })() + if ( + !sb && + (task.uiStep === "step2_copy_ready" || + task.uiStep === "step3_ready" || + task.uiStep === "step3_generating" || + task.uiStep === "step3_done") + ) { + return ( +
+ 暂无分镜数据
) } if ( - task.uiStep === "step2_copy_ready" || - task.uiStep === "step3_ready" || - task.uiStep === "step3_generating" || - task.uiStep === "step3_done" + !sb || + !( + task.uiStep === "step2_copy_ready" || + task.uiStep === "step3_ready" || + task.uiStep === "step3_generating" || + task.uiStep === "step3_done" + ) ) { - // 文案始终可编辑(即便是视频生成中也可以看但锁编辑) - const locked = task.uiStep === "step3_generating" || task.uiStep === "step3_done" - return ( -
-
- - AI 生成文案 - {task.generatedTitle && {task.generatedTitle}} -
-