feat(viral-video): 分镜文案区改为流式文档/剧本排版 #2156
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user