feat(viral-video): 动态视频模型选择器 + 分辨率/时长自动联动
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled

- 新增 GET /viral-video/models API 与 ViralVideoModel 类型
- 页面加载时拉取模型列表,失败时兜底使用默认列表
- 模型下拉选项动态渲染,不支持音频的模型标注「🔇无声」
- 切换模型时自动联动:分辨率降级到该模型支持的最高值;时长超过 max_duration 时自动截断
- 清晰度下拉只显示当前模型支持的分辨率
- 时长下拉隐藏超出模型 max_duration 的选项
- confirmViralCopy 请求体补充 video_model(generateViralCopy/generateViralVideo 之前已带)
- 模型切换会通过现有 useEffect 自动触发积分重新预估
- tsc & vite build 通过
This commit is contained in:
xiaoxia
2026-10-03 15:30:03 +08:00
parent a02232a1c3
commit d7b27c649a
3 changed files with 101 additions and 6 deletions
+6
View File
@@ -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<ViralVideoModel[]>("/viral-video/models").then((r) => r.data)
}
/** ── 三步拆分:前端 mock 辅助函数(后端新接口上线后可替换) ── */
/**
+12
View File
@@ -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) */
@@ -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<PresetVoice[]>([])
const [voicePickerOpen, setVoicePickerOpen] = useState(false)
const [videoModels, setVideoModels] = useState<ViralVideoModel[]>(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} 🔇无声`,
}))}
/>
</div>
<div className="vv-form-row">
@@ -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,
}))
})()}
/>
</div>
<div className="vv-form-row">