feat(viral-video): STEP2 分镜脚本UI - 编导分镜卡/约束/负面提示/折叠口播稿 #2125

Merged
xiaoxia merged 1 commits from fix/2041-storyboard-ui into develop 2026-10-01 12:57:30 +08:00
2 changed files with 733 additions and 57 deletions
@@ -1701,3 +1701,265 @@
grid-template-columns: 1fr;
}
}
/* ── 分镜脚本(Storyboard)样式 ── */
.vv-storyboard {
display: flex;
flex-direction: column;
gap: 14px;
}
.vv-copy-tag {
display: inline-flex;
align-items: center;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
}
.vv-copy-tag-preview {
background: #fef3c7;
color: #92400e;
}
/* 总览卡片 */
.vv-sb-overview {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
padding: 12px;
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
border-radius: 10px;
border: 1px solid #e9e4ff;
}
.vv-sb-ov-field label {
display: block;
font-size: 11px;
color: #6b7280;
margin-bottom: 4px;
}
.vv-sb-input {
height: 32px;
font-size: 13px;
}
/* 分块标题 */
.vv-sb-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.vv-sb-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #374151;
}
/* 镜头卡片 */
.vv-sb-shots {
display: flex;
flex-direction: column;
gap: 10px;
}
.vv-sb-shot {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 12px;
}
.vv-sb-shot-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.vv-sb-shot-num {
background: #7c3aed;
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
}
.vv-sb-time {
height: 28px;
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 6px;
padding: 0 10px;
font-size: 12px;
color: #6b7280;
width: 100px;
outline: none;
}
.vv-sb-time:focus {
border-color: #7c3aed;
}
.vv-sb-shot-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
}
.vv-sb-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.vv-sb-cell-wide {
grid-column: 1 / -1;
}
.vv-sb-cell label {
font-size: 11px;
color: #6b7280;
font-weight: 500;
}
.vv-sb-cell-ta {
min-height: 54px !important;
font-size: 12.5px !important;
padding: 7px 10px !important;
line-height: 1.55 !important;
resize: vertical;
}
.vv-sb-ref-box {
width: 80px;
height: 54px;
border-radius: 8px;
overflow: hidden;
background: #fff;
border: 1px dashed #d1d5db;
display: flex;
align-items: center;
justify-content: center;
}
.vv-sb-ref-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.vv-sb-ref-empty {
font-size: 11px;
color: #9ca3af;
}
/* 标签(硬性约束/负面提示词) */
.vv-sb-taglist {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.vv-sb-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 10px;
border-radius: 14px;
font-size: 12px;
}
.vv-sb-tag-hard {
background: #f3f0ff;
color: #6d28d9;
border: 1px solid #ddd6fe;
}
.vv-sb-tag-neg {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
}
.vv-sb-tag-input {
background: transparent;
border: none;
outline: none;
font-size: 12px;
color: inherit;
min-width: 80px;
padding: 2px 0;
}
.vv-sb-tag-hard .vv-sb-tag-input {
color: #6d28d9;
}
.vv-sb-tag-neg .vv-sb-tag-input {
color: #b91c1c;
}
.vv-sb-tag-del {
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.08);
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
}
.vv-sb-tag-del:hover {
background: rgba(0, 0, 0, 0.18);
}
.vv-sb-tag-add {
border: 1px dashed #d1d5db;
background: transparent;
color: #6b7280;
font-size: 12px;
padding: 3px 10px;
border-radius: 14px;
cursor: pointer;
}
.vv-sb-tag-add:hover {
border-color: #7c3aed;
color: #7c3aed;
}
/* 口播稿折叠 */
.vv-sb-collapse {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 14px;
cursor: pointer;
font-size: 13px;
color: #374151;
font-weight: 500;
transition:
border-color 0.15s,
background 0.15s;
}
.vv-sb-collapse:hover {
border-color: #7c3aed;
background: #faf8ff;
}
.vv-sb-caret {
transition: transform 0.2s;
color: #9ca3af;
font-size: 12px;
}
.vv-sb-caret.open {
transform: rotate(180deg);
color: #7c3aed;
}
.vv-sb-vo {
min-height: 100px !important;
font-size: 13px !important;
line-height: 1.7 !important;
}
@media (max-width: 640px) {
.vv-sb-overview {
grid-template-columns: 1fr;
}
.vv-sb-shot-grid {
grid-template-columns: 1fr;
}
.vv-sb-cell-wide {
grid-column: auto;
}
}
+471 -57
View File
@@ -9,6 +9,8 @@ import {
CaretRightOutlined,
VideoCameraOutlined,
SoundOutlined,
BulbOutlined,
ThunderboltOutlined,
ReloadOutlined,
WarningFilled,
SearchOutlined,
@@ -120,8 +122,8 @@ type TabTask = {
// UI 状态
uiStep: UIStep
imageAnalysis: ImageAnalysisResult | null
generatedCopy: string // 文案结果(可编辑)
generatedTitle: string
// ── 分镜脚本(新结构) ──
storyboard: Storyboard | null
voiceTipsOpen: boolean
copyError?: string
videoError?: string
@@ -166,6 +168,141 @@ const MODELS = [
const VOICE_TIPS =
"支持 MP3/WAV/M4A/AAC/OGG 格式,最大 10MB,时长 ≤30 秒。建议清晰人声、无背景音乐、环境安静;录音请保持距麦克风 15-20cm,音量适中。"
// ── 分镜脚本数据模型(新后端 copy_result 结构,前端先 mock 展示) ──
interface StoryboardShot {
time_range: string
shot_type_angle_movement: string
scene_and_dialogue: string
action_details: string
audio_bgm: string
transition: string
reference_image_index?: number
}
interface Storyboard {
overview: { theme: string; total_duration: string; aspect_ratio: string }
scene_and_lighting: string
shots: StoryboardShot[]
hard_constraints: string[]
negative_prompts: string[]
voiceover_script: string
}
/** 兼容旧 copy_result(final_copy/title/scenes)→ 新 Storyboard 结构 */
function copyResultToStoryboard(cr: any): Storyboard | null {
if (!cr) return null
// 新结构:含 shots/overview 字段直接用
if (Array.isArray(cr.shots) && cr.overview) {
return {
overview: {
theme: cr.overview.theme || cr.title || "",
total_duration: cr.overview.total_duration || "",
aspect_ratio: cr.overview.aspect_ratio || "9:16",
},
scene_and_lighting: cr.scene_and_lighting || "",
shots: (cr.shots as any[]).map((s) => ({
time_range: s.time_range || "",
shot_type_angle_movement: s.shot_type_angle_movement || "",
scene_and_dialogue: s.scene_and_dialogue || s.dialogue || "",
action_details: s.action_details || "",
audio_bgm: s.audio_bgm || "",
transition: s.transition || "",
reference_image_index:
typeof s.reference_image_index === "number" ? s.reference_image_index : undefined,
})),
hard_constraints: Array.isArray(cr.hard_constraints) ? cr.hard_constraints : [],
negative_prompts: Array.isArray(cr.negative_prompts) ? cr.negative_prompts : [],
voiceover_script: cr.voiceover_script || cr.final_copy || cr.suggested_copy || "",
}
}
// 兜底:旧结构转简单分镜
const finalCopy = cr.final_copy || cr.suggested_copy || ""
const title = cr.title || ""
return {
overview: { theme: title || "AI 分镜脚本", total_duration: "", aspect_ratio: "9:16" },
scene_and_lighting: "",
shots:
cr.scenes && cr.scenes.length
? cr.scenes.map((sc: any, i: number) => ({
time_range: sc.duration
? `${i * sc.duration}-${(i + 1) * sc.duration}s`
: `镜头${i + 1}`,
shot_type_angle_movement: sc.shot || "",
scene_and_dialogue: sc.narration || "",
action_details: "",
audio_bgm: "",
transition: "",
}))
: finalCopy
? [
{
time_range: "0-15s",
shot_type_angle_movement: "",
scene_and_dialogue: finalCopy,
action_details: "",
audio_bgm: "",
transition: "",
},
]
: [],
hard_constraints: [],
negative_prompts: [],
voiceover_script: finalCopy,
}
}
/** Mock 分镜脚本(后端 API 未就绪时的占位展示) */
const MOCK_STORYBOARD: Storyboard = {
overview: { theme: "实木餐桌场景化种草", total_duration: "15s", aspect_ratio: "9:16" },
scene_and_lighting:
"暖黄调家居场景,午后阳光从纱帘斜射入餐厅,桌上摆满家常菜,整体营造温馨治愈、高质感的生活氛围。",
shots: [
{
time_range: "0-3s",
shot_type_angle_movement: "特写/俯拍/慢推",
scene_and_dialogue: "(盘子掀开冒热气)还在为餐桌选不到好桌子发愁?",
action_details: "双手掀开白色瓷盘盖,蒸汽缓缓升腾,桌上红烧排骨特写",
audio_bgm: "轻快钢琴+盘子掀开的清脆音效",
transition: "蒸汽模糊转场",
reference_image_index: 0,
},
{
time_range: "3-8s",
shot_type_angle_movement: "中景/平视/稳定",
scene_and_dialogue: "这张北美黑胡桃木餐桌,一家人坐下来吃饭刚刚好。",
action_details: "镜头切到一家人围坐用餐,妈妈夹菜给孩子,爸爸微笑",
audio_bgm: "温馨BGM持续,轻人声环境音",
transition: "切",
reference_image_index: 1,
},
{
time_range: "8-12s",
shot_type_angle_movement: "特写/45度/滑轨横移",
scene_and_dialogue: "全实木、无贴皮,纹理好看又耐刮。",
action_details: "手指轻抚桌面木纹,杯子放在桌上不晃动,展示承重细节",
audio_bgm: "手指划木面的摩擦声",
transition: "切",
reference_image_index: 2,
},
{
time_range: "12-15s",
shot_type_angle_movement: "全景/微仰/拉远",
scene_and_dialogue: "点小黄车,给家里添一张好桌子。",
action_details: "产品全景+CTA字幕弹出,右下角购物车图标放大",
audio_bgm: "叮咚提示音+BGM收尾",
transition: "淡出",
reference_image_index: 0,
},
],
hard_constraints: [
"画面暖调温馨,不可出现冷光",
"人物必须是一家人(父母+孩子),不能单独人物",
"餐桌材质必须显示为实木",
],
negative_prompts: ["冷色调", "模糊", "变形", "水印文字", "卡通风格", "空无一人"],
voiceover_script:
"还在为餐桌选不到好桌子发愁?这张北美黑胡桃木餐桌,一家人坐下来吃饭刚刚好。全实木、无贴皮,纹理好看又耐刮。点小黄车,给家里添一张好桌子。",
}
const fmtSize = (bytes: number) => {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
@@ -194,8 +331,7 @@ const emptyTask = (id: string, title: string): TabTask => ({
videoModel: "seedance-2.5",
uiStep: "step1_upload",
imageAnalysis: null,
generatedCopy: "",
generatedTitle: "",
storyboard: null,
voiceTipsOpen: false,
job: null,
jobId: null,
@@ -208,6 +344,7 @@ const ViralVideoPage: React.FC = () => {
const [tasks, setTasks] = useState<TabTask[]>(() => [emptyTask("t1", "生成 1")])
const [activeId, setActiveId] = useState<string>("t1")
const [cloneModalOpen, setCloneModalOpen] = useState(false)
const [voOpen, setVoOpen] = useState(false) // 口播稿折叠
const [voiceAssets, setVoiceAssets] = useState<
{ id: string; name: string; url: string; desc?: string }[]
>([])
@@ -287,9 +424,10 @@ const ViralVideoPage: React.FC = () => {
// 同步服务端最新的分析/文案结果(若已返回)
const nextImageAnalysis = job.image_analysis || t.imageAnalysis
const nextCopy = job.copy_result
const nextGeneratedCopy =
nextCopy?.final_copy || nextCopy?.suggested_copy || t.generatedCopy
const nextGeneratedTitle = nextCopy?.title || t.generatedTitle
// 新结构分镜 → storyboard;老结构通过 helper 兜底转换
const nextStoryboard = nextCopy
? copyResultToStoryboard(nextCopy) || t.storyboard
: t.storyboard
switch (job.status) {
case "image_analyzed":
@@ -327,8 +465,7 @@ const ViralVideoPage: React.FC = () => {
job,
uiStep: nextStep,
imageAnalysis: nextImageAnalysis,
generatedCopy: nextGeneratedCopy,
generatedTitle: nextGeneratedTitle,
storyboard: nextStoryboard,
copyError:
nextStep === "failed" && isCopyStage(job.progress_stage) ? copyError : copyError,
videoError:
@@ -436,8 +573,7 @@ const ViralVideoPage: React.FC = () => {
images: [],
imageAnalysis: null,
uiStep: "step1_upload",
generatedCopy: "",
generatedTitle: "",
storyboard: null,
job: null,
jobId: null,
}
@@ -454,14 +590,7 @@ const ViralVideoPage: React.FC = () => {
...t,
images: newImgs,
imageAnalysis: newImgs.length === 0 ? null : t.imageAnalysis,
uiStep:
newImgs.length === 0
? "step1_upload"
: t.uiStep === "step1_analyzing" || t.uiStep === "step1_done"
? "step1_upload"
: t.uiStep,
generatedCopy: newImgs.length === 0 ? "" : t.generatedCopy,
generatedTitle: newImgs.length === 0 ? "" : t.generatedTitle,
storyboard: newImgs.length === 0 ? null : t.storyboard,
}
})
}
@@ -682,10 +811,13 @@ const ViralVideoPage: React.FC = () => {
setTask({ uiStep: "step3_generating", videoError: undefined })
try {
let job
if (task.jobId && task.generatedCopy.trim()) {
if (task.jobId && task.storyboard && task.storyboard.voiceover_script.trim()) {
// 新三步链路:基于已有 job 触发 confirm-copy
const edited = task.generatedCopy.trim()
const originalCopy = task.job?.copy_result?.final_copy || ""
const edited = task.storyboard.voiceover_script.trim()
const originalCopy =
(task.job?.copy_result as any)?.voiceover_script ||
task.job?.copy_result?.final_copy ||
""
job = await confirmViralCopy(task.jobId, {
edited_copy: edited && edited !== originalCopy.trim() ? edited : undefined,
})
@@ -698,7 +830,7 @@ const ViralVideoPage: React.FC = () => {
images: imgUrls,
reference_video_url: task.refVideo?.ossUrl || undefined,
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
user_copy_text: task.generatedCopy || task.userCopy || undefined,
user_copy_text: task.storyboard?.voiceover_script || task.userCopy || undefined,
fusion_level: task.fusionLevel,
voice_id: task.refAudio?.id || undefined,
voice_source: task.refAudio?.source,
@@ -733,7 +865,7 @@ const ViralVideoPage: React.FC = () => {
} else {
// 无 jobId 则重置到可重试的上一步
setTask({
uiStep: task.generatedCopy ? "step2_copy_ready" : "step1_done",
uiStep: task.storyboard ? "step2_copy_ready" : "step1_done",
videoError: undefined,
copyError: undefined,
})
@@ -835,51 +967,333 @@ 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 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 } })
}
const renderCopyResult = () => {
if (task.uiStep === "step2_generating") {
return (
<div className="vv-copy-box vv-copy-loading">
<LoadingOutlined style={{ color: "#7c3aed", fontSize: 18, marginRight: 8 }} />
<span>AI 正在创作营销文案…</span>
<span>AI 正在创作视频分镜脚本…</span>
</div>
)
}
// 后端新结构尚未就绪时,用 MOCK_STORYBOARD 做预览展示;新结构到了之后优先用真实数据
const sb: Storyboard | null = (() => {
if (!task.storyboard) return null
const isNewStructure =
task.storyboard.shots.length >= 2 &&
!!task.storyboard.scene_and_lighting &&
task.storyboard.overview.total_duration.length > 0
if (isNewStructure) return task.storyboard
// 后端还在旧结构:兜底用 mock 展示分镜卡片效果
return MOCK_STORYBOARD
})()
if (
!sb &&
(task.uiStep === "step2_copy_ready" ||
task.uiStep === "step3_ready" ||
task.uiStep === "step3_generating" ||
task.uiStep === "step3_done")
) {
return (
<div className="vv-copy-box vv-copy-loading">
<span style={{ color: "#9ca3af" }}>暂无分镜数据</span>
</div>
)
}
if (
task.uiStep === "step2_copy_ready" ||
task.uiStep === "step3_ready" ||
task.uiStep === "step3_generating" ||
task.uiStep === "step3_done"
!sb ||
!(
task.uiStep === "step2_copy_ready" ||
task.uiStep === "step3_ready" ||
task.uiStep === "step3_generating" ||
task.uiStep === "step3_done"
)
) {
// 文案始终可编辑(即便是视频生成中也可以看但锁编辑)
const locked = task.uiStep === "step3_generating" || task.uiStep === "step3_done"
return (
<div className="vv-copy-box">
<div className="vv-copy-head">
<EditOutlined style={{ color: "#7c3aed" }} />
<span className="vv-copy-title">AI 生成文案</span>
{task.generatedTitle && <span className="vv-copy-sub">{task.generatedTitle}</span>}
</div>
<textarea
className="vv-textarea vv-copy-textarea"
value={task.generatedCopy}
onChange={(e) => setTask({ generatedCopy: e.target.value })}
disabled={locked}
placeholder="AI 生成的营销文案将显示在这里,可直接编辑修改…"
/>
{task.uiStep === "step2_copy_ready" && (
<button
className="vv-btn vv-btn-ghost vv-btn-block"
onClick={handleGenerateCopy}
style={{ marginTop: 8 }}
>
<ReloadOutlined /> 重新生成文案
</button>
return null
}
const locked = task.uiStep === "step3_generating" || task.uiStep === "step3_done"
const isMockPreview = sb === MOCK_STORYBOARD
return (
<div className="vv-copy-box vv-storyboard">
<div className="vv-copy-head">
<EditOutlined style={{ color: "#7c3aed" }} />
<span className="vv-copy-title">AI 分镜脚本</span>
{isMockPreview && (
<span className="vv-copy-tag vv-copy-tag-preview">
示例预览 · 等后端输出新结构后自动替换
</span>
)}
</div>
)
}
return null
{/* 1. 总览卡片 */}
<div className="vv-sb-overview">
<div className="vv-sb-ov-field">
<label>主题</label>
<input
className="vv-input vv-sb-input"
value={sb.overview.theme}
disabled={locked}
onChange={(e) => updateOverview({ theme: e.target.value })}
/>
</div>
<div className="vv-sb-ov-field">
<label>总时长</label>
<input
className="vv-input vv-sb-input"
value={sb.overview.total_duration}
disabled={locked}
onChange={(e) => updateOverview({ total_duration: e.target.value })}
/>
</div>
<div className="vv-sb-ov-field">
<label>画幅</label>
<Select
className="vv-select vv-select-purple vv-sb-input"
style={{ width: "100%" }}
value={sb.overview.aspect_ratio}
disabled={locked}
onChange={(v) => updateOverview({ aspect_ratio: v })}
options={RATIOS.map((r) => ({ value: r.v, label: r.label }))}
/>
</div>
</div>
{/* 2. 场景与光线 */}
<div className="vv-sb-section">
<div className="vv-sb-section-title">
<BulbOutlined style={{ color: "#7c3aed" }} /> 场景与光线
</div>
<textarea
className="vv-textarea"
value={sb.scene_and_lighting}
disabled={locked}
onChange={(e) => updateStoryboard({ scene_and_lighting: e.target.value })}
placeholder="描述整体场景氛围、光线方向与色温…"
/>
</div>
{/* 3. 逐镜头卡片 */}
<div className="vv-sb-section">
<div className="vv-sb-section-title">
<VideoCameraOutlined style={{ color: "#7c3aed" }} /> 逐镜头拆解({sb.shots.length} 镜)
</div>
<div className="vv-sb-shots">
{sb.shots.map((sh, idx) => {
const refImg =
typeof sh.reference_image_index === "number"
? task.images[sh.reference_image_index]
: undefined
return (
<div key={idx} className="vv-sb-shot">
<div className="vv-sb-shot-head">
<span className="vv-sb-shot-num">镜头 {idx + 1}</span>
<input
className="vv-sb-time"
value={sh.time_range}
disabled={locked}
onChange={(e) => updateShot(idx, { time_range: e.target.value })}
/>
</div>
<div className="vv-sb-shot-grid">
<div className="vv-sb-cell">
<label>景别 / 运镜</label>
<textarea
className="vv-textarea vv-sb-cell-ta"
value={sh.shot_type_angle_movement}
disabled={locked}
onChange={(e) =>
updateShot(idx, { shot_type_angle_movement: e.target.value })
}
/>
</div>
<div className="vv-sb-cell vv-sb-cell-wide">
<label>场景与对白</label>
<textarea
className="vv-textarea vv-sb-cell-ta"
value={sh.scene_and_dialogue}
disabled={locked}
onChange={(e) => updateShot(idx, { scene_and_dialogue: e.target.value })}
/>
</div>
<div className="vv-sb-cell">
<label>动作细节</label>
<textarea
className="vv-textarea vv-sb-cell-ta"
value={sh.action_details}
disabled={locked}
onChange={(e) => updateShot(idx, { action_details: e.target.value })}
/>
</div>
<div className="vv-sb-cell">
<label>音效 / BGM</label>
<textarea
className="vv-textarea vv-sb-cell-ta"
value={sh.audio_bgm}
disabled={locked}
onChange={(e) => updateShot(idx, { audio_bgm: e.target.value })}
/>
</div>
<div className="vv-sb-cell">
<label>转场</label>
<textarea
className="vv-textarea vv-sb-cell-ta"
value={sh.transition}
disabled={locked}
onChange={(e) => updateShot(idx, { transition: e.target.value })}
/>
</div>
<div className="vv-sb-cell vv-sb-ref">
<label>参考图</label>
<div className="vv-sb-ref-box">
{refImg?.url ? (
<img src={refImg.url} alt={`ref-${idx}`} className="vv-sb-ref-img" />
) : (
<span className="vv-sb-ref-empty">未指定</span>
)}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
{/* 4. 硬性约束 */}
<div className="vv-sb-section">
<div className="vv-sb-section-title">
<ThunderboltOutlined style={{ color: "#7c3aed" }} /> 硬性约束
</div>
<div className="vv-sb-taglist">
{sb.hard_constraints.map((c, i) => (
<span key={i} className="vv-sb-tag vv-sb-tag-hard">
<input
className="vv-sb-tag-input"
value={c}
disabled={locked}
onChange={(e) => updateConstraints("hard_constraints", i, e.target.value)}
/>
{!locked && (
<button
className="vv-sb-tag-del"
onClick={() => removeConstraint("hard_constraints", i)}
aria-label="删除"
>
×
</button>
)}
</span>
))}
{!locked && (
<button className="vv-sb-tag-add" onClick={() => addConstraint("hard_constraints")}>
+ 添加
</button>
)}
</div>
</div>
{/* 5. 负面提示词 */}
<div className="vv-sb-section">
<div className="vv-sb-section-title">
<CloseOutlined style={{ color: "#ef4444" }} /> 负面提示词
</div>
<div className="vv-sb-taglist">
{sb.negative_prompts.map((c, i) => (
<span key={i} className="vv-sb-tag vv-sb-tag-neg">
<input
className="vv-sb-tag-input"
value={c}
disabled={locked}
onChange={(e) => updateConstraints("negative_prompts", i, e.target.value)}
/>
{!locked && (
<button
className="vv-sb-tag-del"
onClick={() => removeConstraint("negative_prompts", i)}
aria-label="删除"
>
×
</button>
)}
</span>
))}
{!locked && (
<button className="vv-sb-tag-add" onClick={() => addConstraint("negative_prompts")}>
+ 添加
</button>
)}
</div>
</div>
{/* 6. 完整口播稿(折叠) */}
<div className="vv-sb-section">
<button className="vv-sb-collapse" onClick={() => setVoOpen(!voOpen)}>
<span>
<SoundOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
完整口播稿(TTS 合成使用)
</span>
<span className={`vv-sb-caret ${voOpen ? "open" : ""}`}>▾</span>
</button>
{voOpen && (
<textarea
className="vv-textarea vv-sb-vo"
value={sb.voiceover_script}
disabled={locked}
onChange={(e) => updateStoryboard({ voiceover_script: e.target.value })}
placeholder="AI 合成配音用的完整口播稿…"
/>
)}
</div>
{/* 7. 重新生成按钮(ghost) */}
{task.uiStep === "step2_copy_ready" && (
<button
className="vv-btn vv-btn-ghost vv-btn-block"
onClick={handleGenerateCopy}
style={{ marginTop: 12 }}
>
<ReloadOutlined /> 重新生成文案
</button>
)}
</div>
)
}
return (