feat(viral-video): 分镜内联流式排版 + 动态视频模型选择器 (#2159)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m12s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m14s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m53s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m52s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m13s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m59s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 11m42s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 18m36s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #2159.
This commit is contained in:
2026-10-03 17:42:44 +08:00
committed by auto-approve-bot
parent 1b02df4d4a
commit b28ac8bd1b
4 changed files with 543 additions and 212 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 辅助函数(后端新接口上线后可替换) ── */
/**
+14
View File
@@ -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) */
@@ -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;
+461 -117
View File
@@ -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<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 +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<StoryboardShot>) => {
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<Storyboard>) => {
if (!task.storyboard) return
setTask({ storyboard: { ...task.storyboard, ...patch } })
}
const updateOverview = (patch: Partial<Storyboard["overview"]>) => {
if (!task.storyboard) return
setTask({
storyboard: { ...task.storyboard, overview: { ...task.storyboard.overview, ...patch } },
const [editingField, setEditingField] = useState<string | null>(null)
const editTaRef = useRef<HTMLTextAreaElement | null>(null)
const updateShot = useCallback((idx: number, patch: Partial<StoryboardShot>) => {
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<Storyboard>) => {
setTask((t) => (t.storyboard ? { ...t, storyboard: { ...t.storyboard, ...patch } } : t))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const updateOverview = useCallback((patch: Partial<Storyboard["overview"]>) => {
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<StoryboardShot>)
} else if (key.startsWith("overview.")) {
const field = key.split(".")[1]
updateOverview({ [field]: val } as Partial<Storyboard["overview"]>)
} 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 = () => {
<div className="vv-sb-doc">
{/* 视频总览 */}
<h4 className="vv-sb-h">视频总览</h4>
<p className="vv-sb-overview">
<strong>整体主题:</strong>
<input
className="vv-sb-inline-text"
value={sb.overview.theme}
disabled={locked}
onChange={(e) => updateOverview({ theme: e.target.value })}
/>
<span className="vv-sb-sep">·</span>
<strong>总时长:</strong>
<input
className="vv-sb-inline-text vv-sb-inline-text-sm"
value={sb.overview.total_duration}
disabled={locked}
onChange={(e) => updateOverview({ total_duration: e.target.value })}
/>
<span className="vv-sb-sep">·</span>
<strong>画幅:</strong>
<p className="vv-sb-inline-row">
<strong className="vv-sb-field-k">整体主题:</strong>
{editingField === "overview.theme" ? (
<textarea
ref={editTaRef}
className="vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
autoFocus
defaultValue={sb.overview.theme}
data-field="overview.theme"
onBlur={handleInlineCommit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleInlineCommit()
}
if (e.key === "Escape") setEditingField(null)
}}
/>
) : (
<span
className="vv-sb-field-val"
onClick={() => {
if (!locked) setEditingField("overview.theme")
}}
>
{sb.overview.theme}
</span>
)}
</p>
<p className="vv-sb-inline-row">
<strong className="vv-sb-field-k">总时长:</strong>
{editingField === "overview.total_duration" ? (
<textarea
ref={editTaRef}
className="vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta vv-sb-inline-edit-ta-sm"
autoFocus
defaultValue={sb.overview.total_duration}
data-field="overview.total_duration"
onBlur={handleInlineCommit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleInlineCommit()
}
if (e.key === "Escape") setEditingField(null)
}}
/>
) : (
<span
className="vv-sb-field-val"
onClick={() => {
if (!locked) setEditingField("overview.total_duration")
}}
>
{sb.overview.total_duration}
</span>
)}
</p>
<p className="vv-sb-inline-row">
<strong className="vv-sb-field-k">画幅:</strong>
<Select
className="vv-select vv-sb-inline-select"
style={{ width: 120 }}
style={{ width: 90 }}
value={sb.overview.aspect_ratio}
disabled={locked}
onChange={(v) => updateOverview({ aspect_ratio: v })}
@@ -1285,13 +1439,33 @@ const ViralVideoPage: React.FC = () => {
{/* 场景与光线 */}
<h4 className="vv-sb-h">场景与光线</h4>
<p className="vv-sb-para">
<textarea
className="vv-textarea vv-sb-doc-ta vv-sb-doc-ta-block"
value={sb.scene_and_lighting}
disabled={locked}
onChange={(e) => updateStoryboard({ scene_and_lighting: e.target.value })}
placeholder="描述整体场景氛围、光线方向与色温…"
/>
{editingField === "scene_and_lighting" ? (
<textarea
ref={editTaRef}
className="vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
autoFocus
defaultValue={sb.scene_and_lighting}
data-field="scene_and_lighting"
onBlur={handleInlineCommit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleInlineCommit()
}
if (e.key === "Escape") setEditingField(null)
}}
placeholder="描述整体场景氛围、光线方向与色温…"
/>
) : (
<span
className="vv-sb-field-val"
onClick={() => {
if (!locked) setEditingField("scene_and_lighting")
}}
>
{sb.scene_and_lighting}
</span>
)}
</p>
{/* 逐镜头 */}
@@ -1302,61 +1476,180 @@ const ViralVideoPage: React.FC = () => {
typeof sh.reference_image_index === "number"
? task.images[sh.reference_image_index]
: undefined
const shotKey = (field: string) => `shot.${idx}.${field}`
return (
<div key={idx} className="vv-sb-doc-shot">
<input
className="vv-sb-time-doc"
value={sh.time_range}
disabled={locked}
onChange={(e) => updateShot(idx, { time_range: e.target.value })}
placeholder="0-3秒"
/>
{editingField === shotKey("time_range") ? (
<textarea
ref={editTaRef}
className="vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta vv-sb-time-ta"
autoFocus
defaultValue={sh.time_range}
data-field={shotKey("time_range")}
onBlur={handleInlineCommit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleInlineCommit()
}
if (e.key === "Escape") setEditingField(null)
}}
placeholder="0-3秒"
/>
) : (
<strong
className="vv-sb-time-doc"
onClick={() => {
if (!locked) setEditingField(shotKey("time_range"))
}}
>
{sh.time_range}
</strong>
)}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">景别/角度与运镜:</strong>
<textarea
className="vv-textarea vv-sb-doc-ta vv-sb-doc-ta-sm vv-sb-doc-ta-inline"
value={sh.shot_type_angle_movement}
disabled={locked}
onChange={(e) =>
updateShot(idx, { shot_type_angle_movement: e.target.value })
}
/>
{editingField === shotKey("shot_type_angle_movement") ? (
<textarea
ref={editTaRef}
className="vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
autoFocus
defaultValue={sh.shot_type_angle_movement}
data-field={shotKey("shot_type_angle_movement")}
onBlur={handleInlineCommit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleInlineCommit()
}
if (e.key === "Escape") setEditingField(null)
}}
/>
) : (
<span
className="vv-sb-field-val"
onClick={() => {
if (!locked) setEditingField(shotKey("shot_type_angle_movement"))
}}
>
{sh.shot_type_angle_movement}
</span>
)}
</p>
<p className="vv-sb-field">
<strong className="vv-sb-field-k">场景与对白:</strong>
<textarea
className="vv-textarea vv-sb-doc-ta vv-sb-doc-ta-inline"
value={sh.scene_and_dialogue}
disabled={locked}
onChange={(e) => updateShot(idx, { scene_and_dialogue: e.target.value })}
/>
{editingField === shotKey("scene_and_dialogue") ? (
<textarea
ref={editTaRef}
className="vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
autoFocus
defaultValue={sh.scene_and_dialogue}
data-field={shotKey("scene_and_dialogue")}
onBlur={handleInlineCommit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleInlineCommit()
}
if (e.key === "Escape") setEditingField(null)
}}
/>
) : (
<span
className="vv-sb-field-val"
onClick={() => {
if (!locked) setEditingField(shotKey("scene_and_dialogue"))
}}
>
{sh.scene_and_dialogue}
</span>
)}
</p>
<p className="vv-sb-field">
<strong className="vv-sb-field-k">动作与真人细节:</strong>
<textarea
className="vv-textarea vv-sb-doc-ta vv-sb-doc-ta-sm vv-sb-doc-ta-inline"
value={sh.action_details}
disabled={locked}
onChange={(e) => updateShot(idx, { action_details: e.target.value })}
/>
{editingField === shotKey("action_details") ? (
<textarea
ref={editTaRef}
className="vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
autoFocus
defaultValue={sh.action_details}
data-field={shotKey("action_details")}
onBlur={handleInlineCommit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleInlineCommit()
}
if (e.key === "Escape") setEditingField(null)
}}
/>
) : (
<span
className="vv-sb-field-val"
onClick={() => {
if (!locked) setEditingField(shotKey("action_details"))
}}
>
{sh.action_details}
</span>
)}
</p>
<p className="vv-sb-field">
<strong className="vv-sb-field-k">音效/BGM:</strong>
<textarea
className="vv-textarea vv-sb-doc-ta vv-sb-doc-ta-sm vv-sb-doc-ta-inline"
value={sh.audio_bgm}
disabled={locked}
onChange={(e) => updateShot(idx, { audio_bgm: e.target.value })}
/>
{editingField === shotKey("audio_bgm") ? (
<textarea
ref={editTaRef}
className="vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
autoFocus
defaultValue={sh.audio_bgm}
data-field={shotKey("audio_bgm")}
onBlur={handleInlineCommit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleInlineCommit()
}
if (e.key === "Escape") setEditingField(null)
}}
/>
) : (
<span
className="vv-sb-field-val"
onClick={() => {
if (!locked) setEditingField(shotKey("audio_bgm"))
}}
>
{sh.audio_bgm}
</span>
)}
</p>
<p className="vv-sb-field">
<strong className="vv-sb-field-k">转场:</strong>
<textarea
className="vv-textarea vv-sb-doc-ta vv-sb-doc-ta-sm vv-sb-doc-ta-inline"
value={sh.transition}
disabled={locked}
onChange={(e) => updateShot(idx, { transition: e.target.value })}
/>
{editingField === shotKey("transition") ? (
<textarea
ref={editTaRef}
className="vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
autoFocus
defaultValue={sh.transition}
data-field={shotKey("transition")}
onBlur={handleInlineCommit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleInlineCommit()
}
if (e.key === "Escape") setEditingField(null)
}}
/>
) : (
<span
className="vv-sb-field-val"
onClick={() => {
if (!locked) setEditingField(shotKey("transition"))
}}
>
{sh.transition}
</span>
)}
</p>
<p className="vv-sb-field vv-sb-ref-row">
<strong className="vv-sb-field-k">参考图片:</strong>
@@ -1458,19 +1751,39 @@ const ViralVideoPage: React.FC = () => {
</div>
{/* 完整口播稿 */}
<h4 className="vv-sb-h">
<SoundOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
完整口播稿(TTS 合成使用)
</h4>
<div className="vv-sb-vo-wrap">
<textarea
className="vv-textarea vv-sb-doc-ta vv-sb-vo"
value={sb.voiceover_script}
disabled={locked}
onChange={(e) => updateStoryboard({ voiceover_script: e.target.value })}
placeholder="AI 合成配音用的完整口播稿…"
/>
</div>
<p className="vv-sb-field">
<strong className="vv-sb-field-k">
<SoundOutlined style={{ color: "#7c3aed", marginRight: 4 }} />
口播稿:
</strong>
{editingField === "voiceover_script" ? (
<textarea
ref={editTaRef}
className="vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
autoFocus
defaultValue={sb.voiceover_script}
data-field="voiceover_script"
onBlur={handleInlineCommit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleInlineCommit()
}
if (e.key === "Escape") setEditingField(null)
}}
placeholder="AI 合成配音用的完整口播稿…"
/>
) : (
<span
className="vv-sb-field-val"
onClick={() => {
if (!locked) setEditingField("voiceover_script")
}}
>
{sb.voiceover_script}
</span>
)}
</p>
</div>
<div className="vv-sb-actions">
@@ -2065,8 +2378,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.display_name,
}))}
/>
</div>
<div className="vv-form-row">
@@ -2076,7 +2409,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">