feat(viral-video): STEP2 分镜脚本UI - 编导分镜卡/约束/负面提示/折叠口播稿 #2125
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user