feat(viral-video): 分镜内联流式排版 + 动态视频模型选择器 #2159
@@ -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 辅助函数(后端新接口上线后可替换) ── */
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user