feat(viral-video): 分镜文案区改为流式文档/剧本排版 #2156

Merged
auto-approve-bot merged 1 commits from feat/viral-video-storyboard-doc-style into develop 2026-10-03 14:30:40 +08:00
2 changed files with 216 additions and 172 deletions
+108 -62
View File
@@ -1091,7 +1091,7 @@
line-height: 1.55;
}
.vv-sb-h {
margin: 8px 0 3px;
margin: 6px 0 2px;
padding: 0;
font-size: 14px;
font-weight: 700;
@@ -1102,48 +1102,58 @@
.vv-sb-h:first-child {
margin-top: 0;
}
.vv-sb-kv {
display: flex;
align-items: flex-start;
gap: 4px;
/* 总览 —— 单行段落 */
.vv-sb-overview {
font-size: 13px;
line-height: 1.55;
}
.vv-sb-k {
flex-shrink: 0;
color: #6b7280;
font-weight: 500;
min-width: 120px;
}
.vv-sb-kv-ref {
align-items: center;
}
.vv-sb-inline {
flex: 1;
border: none;
background: transparent;
line-height: 1.7;
color: #1f2937;
font-size: 13px;
font-family: inherit;
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;
}
.vv-sb-inline-input {
border-bottom: 1px dashed transparent;
transition: border-color 0.15s;
transition:
border-color 0.15s,
background 0.15s;
}
.vv-sb-inline-input:hover,
.vv-sb-inline-input:focus {
border-bottom-color: #7c3aed;
.vv-sb-inline-text:hover,
.vv-sb-inline-text:focus {
background: #f5f0ff;
border-bottom-color: #7c3aed;
}
.vv-sb-inline:disabled {
.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;
}
.vv-sb-inline-select {
min-width: 120px;
min-width: 80px;
}
.vv-sb-inline-select .ant-select-selector {
background: transparent !important;
@@ -1158,17 +1168,15 @@
line-height: 24px !important;
padding-left: 0 !important;
}
/* 段落式 textarea 基础样式 */
.vv-sb-doc-ta {
flex: 1;
min-height: 32px;
background: transparent !important;
border: 1px dashed transparent !important;
padding: 2px 4px !important;
font-size: 13px !important;
line-height: 1.55 !important;
color: #1f2937 !important;
border-radius: 4px;
border-radius: 6px;
resize: vertical;
outline: none;
transition:
border-color 0.15s,
background 0.15s;
@@ -1181,6 +1189,21 @@
.vv-sb-doc-ta-sm {
min-height: 24px;
}
/* 场景与光线 —— 段落样式 */
.vv-sb-para {
margin: 4px 0;
}
.vv-sb-doc-ta-block {
display: block;
width: 100%;
padding: 4px 8px !important;
font-size: 13px !important;
line-height: 1.6 !important;
min-height: 32px;
}
/* 逐镜头 */
.vv-sb-doc-shots {
display: flex;
flex-direction: column;
@@ -1190,25 +1213,58 @@
.vv-sb-doc-shot {
padding-left: 8px;
border-left: 2px solid rgba(124, 58, 237, 0.3);
}
.vv-sb-doc-shot-head {
margin-bottom: 1px;
display: flex;
flex-direction: column;
gap: 2px;
}
.vv-sb-time-doc {
display: inline-block;
font-size: 13px;
font-weight: 700;
color: #7c3aed;
background: transparent;
background: rgba(124, 58, 237, 0.08);
border: none;
outline: none;
padding: 0 4px 1px;
padding: 1px 6px;
font-family: inherit;
border-radius: 4px;
margin-bottom: 4px;
}
.vv-sb-time-doc:focus {
background: #f5f0ff;
}
/* 字段段落 */
.vv-sb-field {
display: block;
margin: 2px 0;
font-size: 13px;
line-height: 1.65;
}
.vv-sb-field-k {
color: #6d28d9;
font-weight: 600;
margin-right: 4px;
}
.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-ref-row {
margin-top: 4px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.vv-sb-ref-badge {
position: relative;
display: inline-flex;
@@ -1274,6 +1330,10 @@
font-size: 12px;
}
/* 标签组段落间距 */
.vv-sb-tags-block {
margin-top: 4px;
}
.vv-sb-taglist {
display: flex;
flex-wrap: wrap;
@@ -1336,37 +1396,23 @@
background: #f5f0ff;
}
.vv-sb-doc-collapse {
background: transparent;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
gap: 6px;
}
.vv-sb-caret {
display: inline-block;
transition: transform 0.2s;
font-size: 10px;
color: #9ca3af;
}
.vv-sb-caret.open {
transform: rotate(180deg);
/* 口播稿 */
.vv-sb-vo-wrap {
margin-top: 4px;
}
.vv-sb-vo {
margin-top: 4px !important;
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 !important;
min-height: 44px;
font-size: 13px !important;
line-height: 1.7 !important;
color: #1f2937 !important;
border: none !important;
resize: vertical;
}
.vv-sb-actions {
+108 -110
View File
@@ -443,7 +443,6 @@ 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 }[]
>([])
@@ -1254,46 +1253,46 @@ const ViralVideoPage: React.FC = () => {
<div className="vv-sb-doc">
{/* 视频总览 */}
<h4 className="vv-sb-h">视频总览</h4>
<div className="vv-sb-kv">
<span className="vv-sb-k">整体主题:</span>
<p className="vv-sb-overview">
<strong>整体主题:</strong>
<input
className="vv-sb-inline vv-sb-inline-input"
className="vv-sb-inline-text"
value={sb.overview.theme}
disabled={locked}
onChange={(e) => updateOverview({ theme: e.target.value })}
/>
</div>
<div className="vv-sb-kv">
<span className="vv-sb-k">总时长:</span>
<span className="vv-sb-sep">·</span>
<strong>总时长:</strong>
<input
className="vv-sb-inline vv-sb-inline-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 })}
/>
</div>
<div className="vv-sb-kv">
<span className="vv-sb-k">画幅:</span>
<span className="vv-sb-sep">·</span>
<strong>画幅:</strong>
<Select
className="vv-select vv-sb-inline-select"
style={{ width: 200 }}
style={{ width: 120 }}
value={sb.overview.aspect_ratio}
disabled={locked}
onChange={(v) => updateOverview({ aspect_ratio: v })}
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
variant="borderless"
/>
</div>
</p>
{/* 场景与光线 */}
<h4 className="vv-sb-h">场景与光线</h4>
<textarea
className="vv-textarea vv-sb-doc-ta"
value={sb.scene_and_lighting}
disabled={locked}
onChange={(e) => updateStoryboard({ scene_and_lighting: e.target.value })}
placeholder="描述整体场景氛围、光线方向与色温…"
/>
<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="描述整体场景氛围、光线方向与色温…"
/>
</p>
{/* 逐镜头 */}
<h4 className="vv-sb-h">逐秒镜头拆解</h4>
@@ -1305,64 +1304,62 @@ const ViralVideoPage: React.FC = () => {
: undefined
return (
<div key={idx} className="vv-sb-doc-shot">
<div className="vv-sb-doc-shot-head">
<input
className="vv-sb-time-doc"
value={sh.time_range}
disabled={locked}
onChange={(e) => updateShot(idx, { time_range: e.target.value })}
placeholder="0-3秒"
/>
</div>
<div className="vv-sb-kv">
<span className="vv-sb-k">景别/角度与运镜:</span>
<input
className="vv-sb-time-doc"
value={sh.time_range}
disabled={locked}
onChange={(e) => updateShot(idx, { time_range: e.target.value })}
placeholder="0-3秒"
/>
<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"
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 })
}
/>
</div>
<div className="vv-sb-kv">
<span className="vv-sb-k">场景与对白:</span>
</p>
<p className="vv-sb-field">
<strong className="vv-sb-field-k">场景与对白:</strong>
<textarea
className="vv-textarea vv-sb-doc-ta"
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 })}
/>
</div>
<div className="vv-sb-kv">
<span className="vv-sb-k">动作与真人细节:</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"
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 })}
/>
</div>
<div className="vv-sb-kv">
<span className="vv-sb-k">音效/BGM:</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"
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 })}
/>
</div>
<div className="vv-sb-kv">
<span className="vv-sb-k">转场:</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"
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 })}
/>
</div>
<div className="vv-sb-kv vv-sb-kv-ref">
<span className="vv-sb-k">参考图片:</span>
</p>
<p className="vv-sb-field vv-sb-ref-row">
<strong className="vv-sb-field-k">参考图片:</strong>
{refImg?.url ? (
<span className="vv-sb-ref-badge">
<img src={refImg.url} alt={`ref-${idx}`} className="vv-sb-ref-thumb" />
@@ -1392,7 +1389,7 @@ const ViralVideoPage: React.FC = () => {
) : (
<span className="vv-sb-muted">未指定</span>
)}
</div>
</p>
</div>
)
})}
@@ -1400,71 +1397,72 @@ const ViralVideoPage: React.FC = () => {
{/* 硬性约束 */}
<h4 className="vv-sb-h">硬性约束</h4>
<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 className="vv-sb-tags-block">
<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>
{/* 负面提示词 */}
<h4 className="vv-sb-h">负面提示词</h4>
<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 className="vv-sb-tags-block">
<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>
{/* 完整口播稿(可折叠) */}
{/* 完整口播稿 */}
<h4 className="vv-sb-h">
<button className="vv-sb-doc-collapse" onClick={() => setVoOpen(!voOpen)} type="button">
<SoundOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
完整口播稿(TTS 合成使用)
<span className={`vv-sb-caret ${voOpen ? "open" : ""}`}>▾</span>
</button>
<SoundOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
完整口播稿(TTS 合成使用)
</h4>
{voOpen && (
<div className="vv-sb-vo-wrap">
<textarea
className="vv-textarea vv-sb-doc-ta vv-sb-vo"
value={sb.voiceover_script}
@@ -1472,7 +1470,7 @@ const ViralVideoPage: React.FC = () => {
onChange={(e) => updateStoryboard({ voiceover_script: e.target.value })}
placeholder="AI 合成配音用的完整口播稿…"
/>
)}
</div>
</div>
<div className="vv-sb-actions">