From b28ac8bd1b086b561c1e36bdfa8ae7a33a75a589 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 3 Oct 2026 17:42:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(viral-video):=20=E5=88=86=E9=95=9C?= =?UTF-8?q?=E5=86=85=E8=81=94=E6=B5=81=E5=BC=8F=E6=8E=92=E7=89=88=20+=20?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E8=A7=86=E9=A2=91=E6=A8=A1=E5=9E=8B=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=20(#2159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/web/src/api/viral-video/index.ts | 6 + apps/web/src/api/viral-video/types.ts | 14 + .../src/pages/viral-video/ViralVideoPage.css | 157 ++--- .../src/pages/viral-video/ViralVideoPage.tsx | 578 ++++++++++++++---- 4 files changed, 543 insertions(+), 212 deletions(-) diff --git a/apps/web/src/api/viral-video/index.ts b/apps/web/src/api/viral-video/index.ts index ac6526517..69ba8ae70 100644 --- a/apps/web/src/api/viral-video/index.ts +++ b/apps/web/src/api/viral-video/index.ts @@ -9,6 +9,7 @@ import type { AnalyzeImagesRequest, GenerateCopyRequest, ConfirmCopyRequest, + ViralVideoModel, } from "./types" /** 创建爆款视频任务 */ @@ -62,6 +63,11 @@ export function estimateViralVideoCredits(params: { .post<{ estimated_credits: number }>("/viral-video/estimate-credits", params) .then((r) => r.data) } + +/** 获取支持的视频模型列表(GET /viral-video/models) */ +export function getViralVideoModels() { + return apiClient.get("/viral-video/models").then((r) => r.data) +} /** ── 三步拆分:前端 mock 辅助函数(后端新接口上线后可替换) ── */ /** diff --git a/apps/web/src/api/viral-video/types.ts b/apps/web/src/api/viral-video/types.ts index f8d8ab36b..92a791ec1 100644 --- a/apps/web/src/api/viral-video/types.ts +++ b/apps/web/src/api/viral-video/types.ts @@ -280,10 +280,24 @@ export interface GenerateCopyRequest { video_model?: string } +/** 视频模型描述(GET /viral-video/models) */ +export interface ViralVideoModel { + key: string + display_name: string + supports_audio: boolean + supported_resolutions: string[] + max_duration: number + /** 计费模式(可选):per_second / per_video 等,后端预留字段 */ + billing_mode?: string + is_default?: boolean +} + /** v1.6 阶段3请求:用户确认/编辑口播文案后开始单次 Seedance 出片(POST /viral-video/{id}/confirm-copy) */ export interface ConfirmCopyRequest { /** 用户编辑后的口播文案;为空则使用 AI 生成的 voiceover_script */ edited_copy?: string + /** 视频模型 key,覆盖默认 */ + video_model?: string } /** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */ diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css index c2a770d1a..aa152c3ca 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.css +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -1002,6 +1002,11 @@ .vv-form-row { margin-bottom: 10px; } +.vv-form-hint { + font-size: 12px; + color: #9ca3af; + line-height: 1.4; +} @media (max-width: 500px) { .vv-form-grid { grid-template-columns: 1fr; @@ -1103,53 +1108,15 @@ margin-top: 0; } -/* 总览 —— 单行段落 */ -.vv-sb-overview { +/* 总览 —— 每行一段 */ +.vv-sb-inline-row { + display: flex; + align-items: baseline; + flex-wrap: wrap; font-size: 13px; line-height: 1.7; color: #1f2937; - margin: 4px 0 6px; - padding: 4px 8px; - background: #fafafe; - border-radius: 6px; -} -.vv-sb-overview strong { - color: #374151; - font-weight: 600; -} -.vv-sb-overview .vv-sb-inline-text, -.vv-sb-overview .ant-select { - vertical-align: middle; -} -.vv-sb-inline-text { - background: transparent; - border: none; - color: #1f2937; - font: inherit; - padding: 1px 4px; - outline: none; - border-radius: 4px; - border-bottom: 1px dashed transparent; - transition: - border-color 0.15s, - background 0.15s; -} -.vv-sb-inline-text:hover, -.vv-sb-inline-text:focus { - background: #f5f0ff; - border-bottom-color: #7c3aed; -} -.vv-sb-inline-text:disabled { - color: #9ca3af; - cursor: default; -} -.vv-sb-inline-text-sm { - width: auto; - max-width: 80px; -} -.vv-sb-sep { - color: #d1d5db; - margin: 0 8px; + margin: 2px 0; } .vv-sb-inline-select { @@ -1169,7 +1136,7 @@ padding-left: 0 !important; } -/* 段落式 textarea 基础样式 */ +/* 段落式 textarea 基础样式(仅编辑态使用) */ .vv-sb-doc-ta { background: transparent !important; border: 1px dashed transparent !important; @@ -1186,21 +1153,36 @@ border-color: #7c3aed !important; background: #f5f0ff !important; } -.vv-sb-doc-ta-sm { - min-height: 24px; -} /* 场景与光线 —— 段落样式 */ .vv-sb-para { margin: 4px 0; } -.vv-sb-doc-ta-block { + +/* 内联编辑 textarea(点击后弹出) */ +.vv-sb-inline-edit-ta { display: block; width: 100%; - padding: 4px 8px !important; + margin-top: 4px; + min-height: 28px; + background: #fafafe !important; + border: 1px solid #d8cafc !important; + border-radius: 6px !important; + padding: 6px 8px !important; font-size: 13px !important; line-height: 1.6 !important; - min-height: 32px; + font-family: inherit; + color: #1f2937 !important; + outline: none; + resize: vertical; +} +.vv-sb-inline-edit-ta-sm { + max-width: 120px; +} +.vv-sb-time-ta { + max-width: 160px; + font-weight: 600; + color: #7c3aed !important; } /* 逐镜头 */ @@ -1211,49 +1193,51 @@ margin-top: 2px; } .vv-sb-doc-shot { - padding-left: 8px; - border-left: 2px solid rgba(124, 58, 237, 0.3); display: flex; flex-direction: column; gap: 2px; + margin-bottom: 6px; } .vv-sb-time-doc { - display: inline-block; - font-size: 13px; - font-weight: 700; + display: block; + font-size: 14px; + font-weight: 600; color: #7c3aed; - background: rgba(124, 58, 237, 0.08); - border: none; - outline: none; - padding: 1px 6px; - font-family: inherit; - border-radius: 4px; - margin-bottom: 4px; + margin: 6px 0 2px; + cursor: text; } -.vv-sb-time-doc:focus { - background: #f5f0ff; +.vv-sb-time-doc:hover { + background: rgba(124, 58, 237, 0.06); + border-radius: 3px; } /* 字段段落 */ .vv-sb-field { - display: block; + display: flex; + flex-wrap: wrap; + align-items: baseline; margin: 2px 0; font-size: 13px; - line-height: 1.65; + line-height: 1.6; } .vv-sb-field-k { - color: #6d28d9; + color: #1f2937; font-weight: 600; - margin-right: 4px; + margin-right: 0; + white-space: nowrap; } -.vv-sb-doc-ta-inline { - display: block; - width: 100%; - margin-top: 1px; - padding: 2px 4px !important; - font-size: 13px !important; - line-height: 1.6 !important; - min-height: 24px; +.vv-sb-field-val { + color: #374151; + cursor: text; + border-radius: 3px; + padding: 0 2px; + transition: background 0.15s; + word-break: break-word; + flex: 1; + min-width: 0; +} +.vv-sb-field:hover .vv-sb-field-val { + background: rgba(124, 58, 237, 0.06); } /* 参考图片行 */ @@ -1396,24 +1380,7 @@ background: #f5f0ff; } -/* 口播稿 */ -.vv-sb-vo-wrap { - margin-top: 4px; -} -.vv-sb-vo { - display: block; - width: 100%; - background: #fafafe !important; - border-left: 3px solid #7c3aed !important; - border-radius: 0 6px 6px 0 !important; - padding: 6px 10px !important; - min-height: 44px; - font-size: 13px !important; - line-height: 1.7 !important; - color: #1f2937 !important; - border: none !important; - resize: vertical; -} +/* 口播稿 —— 复用 vv-sb-field 样式,无额外需求 */ .vv-sb-actions { display: flex; diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index 4f31cccea..bd8309789 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -40,6 +40,7 @@ import { type ImageAnalysisResult, type CopyResult, type ShotScript, + type ViralVideoModel, } from "@/api/viral-video/types" import { generateViralVideo, @@ -48,6 +49,7 @@ import { generateViralCopy, confirmViralCopy, estimateViralVideoCredits, + getViralVideoModels, } from "@/api/viral-video" import { useViralVideoPolling } from "./hooks/useViralVideoPolling" import CloneModal from "@/components/voice/CloneModal" @@ -217,10 +219,46 @@ const RATIOS = [ { v: "16:9", label: "16:9 横屏(B站/YouTube)" }, { v: "1:1", label: "1:1 方形(小红书)" }, ] -const MODELS = [ - { v: "seedance-2.5", label: "Seedance 2.5(推荐)" }, - { v: "seedance-2.0", label: "Seedance 2.0" }, +/** 兜底模型列表(接口未返回时使用,字段与 ViralVideoModel 对齐;后端返回后自动覆盖) */ +const FALLBACK_VIDEO_MODELS: ViralVideoModel[] = [ + { + key: "seedance-2.5", + display_name: "Seedance 2.5 — 最新最强", + supports_audio: true, + supported_resolutions: ["480p", "720p", "1080p"], + max_duration: 30, + is_default: true, + }, + { + key: "seedance-2.0", + display_name: "Seedance 2.0 — 正式首选", + supports_audio: true, + supported_resolutions: ["480p", "720p", "1080p"], + max_duration: 15, + }, + { + key: "seedance-2.0-fast", + display_name: "Seedance 2.0 Fast — 快速测试", + supports_audio: true, + supported_resolutions: ["480p", "720p"], + max_duration: 15, + }, + { + key: "seedance-2.0-mini", + display_name: "Seedance 2.0 Mini — 低成本", + supports_audio: true, + supported_resolutions: ["480p", "720p"], + max_duration: 15, + }, + { + key: "wan-3.0", + display_name: "Wan 3.0 — 通义万相全能", + supports_audio: true, + supported_resolutions: ["480p", "720p", "1080p"], + max_duration: 15, + }, ] +const RESOLUTION_ORDER = ["480p", "720p", "1080p", "4k"] const QUALITY_OPTIONS = [ { v: "480p", label: "480p(快速)" }, { v: "720p", label: "720p(清晰)" }, @@ -448,6 +486,7 @@ const ViralVideoPage: React.FC = () => { >([]) const [presetVoices, setPresetVoices] = useState([]) const [voicePickerOpen, setVoicePickerOpen] = useState(false) + const [videoModels, setVideoModels] = useState(FALLBACK_VIDEO_MODELS) const [assetPicker, setAssetPicker] = useState<{ open: boolean kind: "image" | "video" | "voice" @@ -507,6 +546,33 @@ const ViralVideoPage: React.FC = () => { }) }, []) + /* ── 加载视频模型列表 ── */ + useEffect(() => { + getViralVideoModels() + .then((list) => { + if (!list || list.length === 0) return + setVideoModels(list) + // 如果当前选中的模型不在返回列表里,切换到默认模型并联动修正参数 + setTask((t) => { + const inList = list.some((m) => m.key === t.videoModel) + if (inList) return t + const def = list.find((m) => m.is_default) || list[0] + const sortedRes = def.supported_resolutions.slice().sort((a, b) => { + const ra = RESOLUTION_ORDER.indexOf(a) + const rb = RESOLUTION_ORDER.indexOf(b) + return (rb === -1 ? -1 : rb) - (ra === -1 ? -1 : ra) + }) + const newRes = sortedRes[0] || "480p" + const newDur = Math.min(t.duration, def.max_duration) + return { ...t, videoModel: def.key, quality: newRes, duration: newDur } + }) + }) + .catch(() => { + // 接口 404/500 时使用兜底列表,不提示用户 + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + /* ── 轮询(job 从 STEP1 开始即存在,贯穿三步) ── */ const onPollUpdate = useCallback( (job: ViralVideoJob) => { @@ -969,6 +1035,7 @@ const ViralVideoPage: React.FC = () => { "" job = await confirmViralCopy(task.jobId, { edited_copy: edited && edited !== originalCopy.trim() ? edited : undefined, + video_model: task.videoModel, }) } else { // 兜底:走旧 /generate 接口(一次性跑完) @@ -1163,40 +1230,85 @@ 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 [editingField, setEditingField] = useState(null) + const editTaRef = useRef(null) + + const updateShot = useCallback((idx: number, patch: Partial) => { + setTask((t) => { + if (!t.storyboard) return t + const shots = t.storyboard.shots.map((s, i) => (i === idx ? { ...s, ...patch } : s)) + return { ...t, storyboard: { ...t.storyboard, shots } } }) - } - 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 } }) - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const updateStoryboard = useCallback((patch: Partial) => { + setTask((t) => (t.storyboard ? { ...t, storyboard: { ...t.storyboard, ...patch } } : t)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const updateOverview = useCallback((patch: Partial) => { + setTask((t) => + t.storyboard + ? { + ...t, + storyboard: { ...t.storyboard, overview: { ...t.storyboard.overview, ...patch } }, + } + : t, + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const updateConstraints = useCallback( + (list: "hard_constraints" | "negative_prompts", idx: number, v: string) => { + setTask((t) => { + if (!t.storyboard) return t + const arr = [...t.storyboard[list]] + arr[idx] = v + return { ...t, storyboard: { ...t.storyboard, [list]: arr } } + }) + }, + [], // eslint-disable-line react-hooks/exhaustive-deps + ) + const addConstraint = useCallback((list: "hard_constraints" | "negative_prompts") => { + setTask((t) => + t.storyboard + ? { ...t, storyboard: { ...t.storyboard, [list]: [...t.storyboard[list], ""] } } + : t, + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const removeConstraint = useCallback( + (list: "hard_constraints" | "negative_prompts", idx: number) => { + setTask((t) => { + if (!t.storyboard) return t + const arr = t.storyboard[list].filter((_, i) => i !== idx) + return { ...t, storyboard: { ...t.storyboard, [list]: arr } } + }) + }, + [], // eslint-disable-line react-hooks/exhaustive-deps + ) + + /* 内联编辑 helper —— 点击文本 → textarea,blur/Enter 保存 */ + const handleInlineCommit = useCallback(() => { + const ta = editTaRef.current + if (!ta) return + const key = ta.dataset.field + if (key) { + const val = ta.value + if (key.startsWith("shot.")) { + const parts = key.split(".") + const idx = Number(parts[1]) + const field = parts.slice(2).join(".") + updateShot(idx, { [field]: val } as Partial) + } else if (key.startsWith("overview.")) { + const field = key.split(".")[1] + updateOverview({ [field]: val } as Partial) + } else if (key === "scene_and_lighting") { + updateStoryboard({ scene_and_lighting: val }) + } else if (key === "voiceover_script") { + updateStoryboard({ voiceover_script: val }) + } + } + setEditingField(null) + }, [updateShot, updateOverview, updateStoryboard]) const renderCopyResult = () => { if (task.uiStep === "step2_generating") { @@ -1253,27 +1365,69 @@ const ViralVideoPage: React.FC = () => {
{/* 视频总览 */}

视频总览

-

- 整体主题: - updateOverview({ theme: e.target.value })} - /> - · - 总时长: - updateOverview({ total_duration: e.target.value })} - /> - · - 画幅: +

+ 整体主题: + {editingField === "overview.theme" ? ( +