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..c62678cd8 100644 --- a/apps/web/src/api/viral-video/types.ts +++ b/apps/web/src/api/viral-video/types.ts @@ -280,10 +280,22 @@ 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 + 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.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index 7a760c33b..d81a84773 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,25 @@ 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, + }, ] +const RESOLUTION_ORDER = ["480p", "720p", "1080p", "4k"] const QUALITY_OPTIONS = [ { v: "480p", label: "480p(快速)" }, { v: "720p", label: "720p(清晰)" }, @@ -448,6 +465,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 +525,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 +1014,7 @@ const ViralVideoPage: React.FC = () => { "" job = await confirmViralCopy(task.jobId, { edited_copy: edited && edited !== originalCopy.trim() ? edited : undefined, + video_model: task.videoModel, }) } else { // 兜底:走旧 /generate 接口(一次性跑完) @@ -2293,8 +2339,28 @@ const ViralVideoPage: React.FC = () => { className="vv-select vv-select-step3" style={{ width: "100%" }} value={task.videoModel} - onChange={(v) => setTask({ videoModel: v })} - options={MODELS.map((m) => ({ value: m.v, label: m.label }))} + onChange={(v) => { + const m = videoModels.find((x) => x.key === v) + if (!m) { + setTask({ videoModel: v }) + return + } + // 切换模型时自动联动修正分辨率/时长 + const sortedRes = m.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 = m.supported_resolutions.includes(task.quality) + ? task.quality + : sortedRes[0] || "480p" + const newDur = Math.min(task.duration, m.max_duration) + setTask({ videoModel: v, quality: newRes, duration: newDur }) + }} + options={videoModels.map((m) => ({ + value: m.key, + label: m.supports_audio ? m.display_name : `${m.display_name} 🔇无声`, + }))} />
@@ -2304,7 +2370,18 @@ const ViralVideoPage: React.FC = () => { style={{ width: "100%" }} value={task.quality} onChange={(v) => setTask({ quality: v })} - options={QUALITY_OPTIONS.map((m) => ({ value: m.v, label: m.label }))} + options={(() => { + const curM = videoModels.find((m) => m.key === task.videoModel) + const supported = curM?.supported_resolutions + const pool = + supported && supported.length > 0 + ? supported + : QUALITY_OPTIONS.map((o) => o.v) + return QUALITY_OPTIONS.filter((o) => pool.includes(o.v)).map((o) => ({ + value: o.v, + label: o.label, + })) + })()} />