feat(core): 脚本 agent 体感打磨 — skill松绑/对白去重/模型自写收尾/品牌遮罩/删除提速

- skill:旁白/对白每镜交给模型按内容判断(去掉「默认旁白·对白按门槛」);visual 加厚撑满15秒
- 脚本 agent:输出协议加收尾交付语 + _closing_summary → summary 事件(替写死的「已生成」)
- 前端:有对白的镜不再重复显旁白;summary 当 AI 气泡;进度时间轴 @keyframes 嵌套失效→移顶层(动画真跑起来)
- 生成遮罩改品牌生成卡(icon-box转圈+标题+动态点+橙扫光条);AI全生确定后不再闪三菜单
- 删除分镜:不再误显改写罩子(拆 rewriteShot);放开「至少保留一个分镜」可删空;删除提速(后端 bulk_update + 前端 liteRefresh)
- 去掉脚本助手标题旁冗余的模型名

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-18 14:54:33 +08:00
co-authored by Claude Opus 4.8
parent 9fce7e8fff
commit a71be19500
7 changed files with 127 additions and 36 deletions
+54 -19
View File
@@ -489,7 +489,7 @@ export function PipelinePage(props: {
}) {
const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
scriptModelName, textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
@@ -889,14 +889,20 @@ export function PipelinePage(props: {
const [armedDelete, setArmedDelete] = useState<string | null>(null);
// 行35 · 单条分镜重跑 / 删除的即时反馈:正在处理的 shot id(按钮转「处理中」并禁用)
const [busyShot, setBusyShot] = useState<string | null>(null);
// 整体生成中:给整个镜头脚本区盖罩子转圈
const [scriptBusy, setScriptBusy] = useState(false);
// 单镜「改写中」:只在重跑/改稿那一镜盖罩子。与 busyShot 区分——删除也占 busyShot,但删除不该显「改写」罩子
const [rewriteShot, setRewriteShot] = useState<string | null>(null);
// 行35 · 单条分镜重跑:调后端 rerun-script-segment 只重写该镜(instruction 带本镜要点微调),给即时反馈
async function rerunShot(shotId: string, _index: number, hint: string) {
if (busyShot) return;
setBusyShot(shotId);
setRewriteShot(shotId); // 重跑 = 改写 → 那一镜盖「改写中」罩子
try {
await onRerunShot?.(shotId, hint || undefined);
} finally {
setBusyShot(null);
setRewriteShot(null);
}
}
// 单条分镜删除:立即反馈(置 busy)再落库
@@ -933,8 +939,12 @@ export function PipelinePage(props: {
// 指定镜号 = 精准改一镜,强制走 revise(后端读全脚本上下文、只动那一镜)
const agentMode = targetIndex != null ? "revise" : (mode ?? mapSourceToMode(source ?? chatMode));
const baseVersionId = agentMode === "revise" ? currentScript?.id : undefined;
// 生成时给左侧上罩子:指定镜号 → 只盖那一镜(busyShot);否则 → 盖整个脚本区(scriptBusy)
const targetShotId = targetIndex != null ? (shots[targetIndex]?.id ?? null) : null;
if (targetShotId) { setBusyShot(targetShotId); setRewriteShot(targetShotId); } else setScriptBusy(true);
let ok = false;
let receivedEvent = false;
let summaryText = "";
try {
await api.agentScriptStream(
project.id,
@@ -979,6 +989,9 @@ export function PipelinePage(props: {
const steps = (m.steps ?? []).map((s) => (s.id === "generate" ? { ...s, think: false } : s));
return { ...m, stream: (m.stream ?? "") + piece, steps };
}));
} else if (evt.type === "summary" && typeof evt.text === "string") {
// 模型自己写的收尾交付语 → 当 AI 回复气泡(替掉写死的「已生成」)
summaryText = evt.text;
} else if (evt.type === "saved") {
ok = true;
} else if (evt.type === "error") {
@@ -997,26 +1010,30 @@ export function PipelinePage(props: {
// 流中途断了(后端已通过 finally 释放预扣额度):不重试,提示用户
pushMsg("ai", "生成中断了,请重试。");
}
} finally {
if (targetShotId) { setBusyShot(null); setRewriteShot(null); } else setScriptBusy(false);
}
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
if (ok) {
await onRefreshProject();
pushMsg("ai", "镜头脚本已生成,左侧已刷新。可继续输入修改意见(会基于当前脚本改稿),或点「确认脚本」进入下一步。");
// 模型写了收尾就用它(真 agent 感);没写则兜底默认句
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
}
}
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
async function runScriptWithSetup() {
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
setSetupOpen(false);
// 行28 · 持久化所选风格/人物到 metadata.wizard(合并现有 wizard,不冲掉 duration/selling_point_ids);
// 顶部「风格/人物」brief pill 读 metadata.wizard,刷新后即显已确认值。
// 先 await 落库再发起生成:action 有 in-flight 互斥锁,并发会被「操作进行中」挡掉
// 一句话主题没填:不关卡、不存档,提示补主题(否则空主题也走下去 → 关卡空窗期闪回三个菜单)
if (setupSource === "theme" && !chatText.trim()) { focusThemeMode(); return; }
// 行28 · 持久化所选风格/人物到 metadata.wizard(合并现有 wizard,不冲掉 duration/selling_point_ids)。
// 关键修复:先 await 落库(此时设定卡仍开着、确定按钮 disabled),存完再「关卡 + 发消息」同一帧切换,
// 不再出现「卡已关但消息未发」的空窗 → 不闪回三个菜单。
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
setSetupOpen(false);
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
if (setupSource === "theme") {
const theme = chatText.trim();
if (!theme) { focusThemeMode(); return; }
setChatText("");
await runScriptGeneration(`一句话主题:${theme}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${theme} · ${styleLabel} · ${personaLabel}`, "theme");
return;
@@ -2130,6 +2147,17 @@ export function PipelinePage(props: {
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑,适合短视频投放", "整体重写")}>↻ 整体重写</button>
</div>
<div className="shots-body" id="shots-body">
{scriptBusy ? (
<div className="script-gen-veil" aria-hidden="true">
<div className="sgv-card">
<span className="sgv-ico"><span className="spinner"></span></span>
<div className="sgv-body">
<div className="sgv-title">正在生成镜头脚本<span className="sgv-dots"><i></i><i></i><i></i></span></div>
<div className="sgv-bar"></div>
</div>
</div>
</div>
) : null}
{shots.length ? (() => {
let cum = 0;
return shots.map((shot, index) => {
@@ -2142,6 +2170,9 @@ export function PipelinePage(props: {
return (
<Fragment key={shot.id}>
<div className="shot-card">
{rewriteShot === shot.id ? (
<div className="shot-gen-veil" aria-hidden="true"><span className="spinner"></span><span className="mono">// 改这一镜…</span></div>
) : null}
<div className="shot-n">{index + 1}</div>
<div className="shot-main">
<div className="shot-meta-row">
@@ -2153,7 +2184,7 @@ export function PipelinePage(props: {
className={`icon-mini-btn${armedDelete === shot.id ? " armed" : ""}`}
type="button"
title={armedDelete === shot.id ? "再点一次确认删除" : "删除本场"}
disabled={loading || shots.length <= 1 || busyShot === shot.id}
disabled={loading || busyShot === shot.id}
onClick={() => {
if (armedDelete === shot.id) {
void deleteShot(shot.id);
@@ -2164,14 +2195,17 @@ export function PipelinePage(props: {
>{armedDelete === shot.id ? "确认删除?" : "×"}</button>
</div>
</div>
<div className="shot-row">
<span className="shot-k">旁白</span>
<EditableShotField
value={narration}
placeholder="(旁白)点击编辑"
onCommit={(text) => { void onUpdateShot({ segment_id: shot.id, narration: text }); }}
/>
</div>
{/* 这一镜有对白时,旁白=对白拼接(同一句),只显对白、不再重复显旁白 */}
{!(shot.dialogue && shot.dialogue.length > 0) ? (
<div className="shot-row">
<span className="shot-k">旁白</span>
<EditableShotField
value={narration}
placeholder="(旁白)点击编辑"
onCommit={(text) => { void onUpdateShot({ segment_id: shot.id, narration: text }); }}
/>
</div>
) : null}
<div className="shot-row">
<span className="shot-k">画面</span>
<EditableShotField
@@ -2234,7 +2268,6 @@ export function PipelinePage(props: {
<div className="pane-h">
<div className="ai-avatar">AI</div>
<strong>脚本助手</strong>
<span className="muted-2 mono" style={{ fontSize: "12px" }}>· {scriptModelName}</span>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0 && chatMsgs.length === 0} onClick={clearChat}>清空对话</button>
</div>
@@ -2352,8 +2385,10 @@ export function PipelinePage(props: {
</div>
) : null}
<span className="spacer"></span>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || (!setupOpen && !chatText.trim() && pendingTagEdits.length === 0)} onClick={submitChat}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || scriptBusy || !!rewriteShot || (!setupOpen && !chatText.trim() && pendingTagEdits.length === 0)} onClick={submitChat}>
{scriptBusy || rewriteShot
? <span className="spinner spinner-on-accent" aria-hidden="true"></span>
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>}
</button>
</div>
</div>