feat(core): 角色对白(剧情向按需)+ 聊天精准改一镜 + 模型下拉移位 + 全流程e2e + 交叉验证修复
新能力(均已端到端验证):
- 角色对白 dialogue:[{speaker,line}](speaker=null 即旁白)。默认口播,用户在聊天提要求才出多角色对白(剧情向);narration 保留扁平拼接兼容下游字幕/配音。skill 同步:默认不强制对白。
- 聊天精准改一镜:聊天说「第N镜改XX」→ agent 读全脚本上下文、只重写那一镜(target_index + 后端 _merge_single_segment 强制保其余镜原样),保衔接。
- 前端:分镜卡渲染对白/镜型/露出;模型下拉从顶部移到输入框下方小按钮(对齐 ChatGPT/Lovart)。
全流程 e2e 真跑通:商品→脚本→基础资产(gpt-image-2)→故事板(@图N 多图合成锁脸锁商品)→视频(Seedance 6.4分钟出片,带音效+人声)。
对抗式交叉验证(3 审查员)修复:
- 改稿用基准稿时长 effective_duration(prompt head + normalize + merge),避免请求默认 60 把 90s/6镜稿尾镜截掉/单镜改空转。
- target_index 越界服务端早返回报错、不计费;模型未产出目标镜时抛错释放额度(不静默空转)。
- 前端「第N镜」正则收窄为 镜|场(去掉会误判「第3个卖点」的 个|段)。
- 回归:测试 setUp 停用 seed 中转站文本模型,保证命中可 mock 的 provider(18/18 过)。
CLAUDE.md 加「AI 生成 Agent 化架构」一节供后续维护。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
163a6627ba
commit
3d38c173e6
@@ -235,6 +235,7 @@ export const api = {
|
||||
base_version_id?: string;
|
||||
aspect_ratio?: string;
|
||||
total_duration?: number;
|
||||
target_index?: number;
|
||||
},
|
||||
onEvent: (evt: { type: string; [k: string]: unknown }) => void
|
||||
): Promise<void> {
|
||||
|
||||
@@ -421,6 +421,9 @@ export function PipelinePage(props: {
|
||||
null;
|
||||
const scriptAdopted = Boolean(currentScript?.is_adopted);
|
||||
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
// 对白 speaker(entity id)→ 角色名;null = 旁白。entities 在 ScriptVersion.metadata
|
||||
const scriptEntities = ((currentScript?.metadata as { entities?: Array<{ id: string; name: string }> } | undefined)?.entities) ?? [];
|
||||
const entityName = (id: string | null | undefined) => (id ? (scriptEntities.find((e) => e.id === id)?.name || id) : "旁白");
|
||||
// 行34 · 脚本里的「人物 / 场景」标签:持久化进 project.metadata.cast / .scenes。
|
||||
// 初值从 metadata 读,刷新/重进后仍在;增删后 onSaveProjectMeta 合并落库。
|
||||
const [castTags, setCastTags] = useState<string[]>(() => project.metadata?.cast ?? []);
|
||||
@@ -649,11 +652,12 @@ export function PipelinePage(props: {
|
||||
if (src === "revise") return "revise";
|
||||
return "auto"; // ai / manual / 默认
|
||||
}
|
||||
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string, mode?: "auto" | "theme" | "revise") {
|
||||
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string, mode?: "auto" | "theme" | "revise", targetIndex?: number) {
|
||||
pushMsg("user", userLabel || prompt);
|
||||
const progressId = nextMsgId();
|
||||
setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [], stream: "", done: false, time: nowHm() }]);
|
||||
const agentMode = mode ?? mapSourceToMode(source ?? chatMode);
|
||||
// 指定镜号 = 精准改一镜,强制走 revise(后端读全脚本上下文、只动那一镜)
|
||||
const agentMode = targetIndex != null ? "revise" : (mode ?? mapSourceToMode(source ?? chatMode));
|
||||
const baseVersionId = agentMode === "revise" ? currentScript?.id : undefined;
|
||||
let ok = false;
|
||||
let receivedEvent = false;
|
||||
@@ -666,7 +670,8 @@ export function PipelinePage(props: {
|
||||
model_config_id: activeScriptModelId || undefined,
|
||||
base_version_id: baseVersionId,
|
||||
aspect_ratio: "9:16",
|
||||
total_duration: 60
|
||||
total_duration: 60,
|
||||
target_index: targetIndex
|
||||
},
|
||||
(evt) => {
|
||||
receivedEvent = true;
|
||||
@@ -748,8 +753,19 @@ export function PipelinePage(props: {
|
||||
setChatText("");
|
||||
setChatAttachments([]);
|
||||
setPendingTagEdits([]);
|
||||
// 已有脚本 → 追问走「改稿」模式(基于当前脚本增强,保留原意);否则全自动出稿
|
||||
void runScriptGeneration(prompt, label || undefined, undefined, currentScript ? "revise" : "auto");
|
||||
// 检测「第N镜 / 场N / 第N个 / 第N段」→ 精准只改那一镜(有当前脚本时);否则整版改稿/出稿
|
||||
let targetIndex: number | undefined;
|
||||
if (currentScript && shots.length) {
|
||||
// 只认「第N镜 / 第N场」;「个/段」太宽(会撞「第3个卖点」「第2段文案」等整体改稿诉求)故不触发
|
||||
const m = text.match(/第\s*([0-9]{1,2}|[一二两三四五六七八九十])\s*(?:镜|场)/);
|
||||
if (m) {
|
||||
const cn: Record<string, number> = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
|
||||
const n = /^[0-9]+$/.test(m[1]) ? parseInt(m[1], 10) : (cn[m[1]] ?? 0);
|
||||
if (n >= 1 && n <= shots.length) targetIndex = n - 1;
|
||||
}
|
||||
}
|
||||
// 已有脚本 → 追问走「改稿」(指定镜号则只改那镜);否则全自动出稿
|
||||
void runScriptGeneration(prompt, label || undefined, undefined, currentScript ? "revise" : "auto", targetIndex);
|
||||
}
|
||||
function clearChat() {
|
||||
setPendingTagEdits([]);
|
||||
@@ -1846,6 +1862,17 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 角色对白(剧情向):speaker→角色名 / 旁白;只读展示 */}
|
||||
{shot.dialogue && shot.dialogue.length > 0 ? (
|
||||
<div className="shot-row">
|
||||
<span className="shot-k">对白</span>
|
||||
<div className="shot-v" style={{ opacity: 0.9 }}>
|
||||
{shot.dialogue.map((d, di) => (
|
||||
<div key={di}><b>{entityName(d.speaker)}:</b>{d.line}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* 行34 · 该卡之后挂着的本地草稿分镜(点「添加分镜」插入,可编辑,有内容失焦才落库) */}
|
||||
@@ -1878,19 +1905,7 @@ export function PipelinePage(props: {
|
||||
<div className="pane-h">
|
||||
<div className="ai-avatar">AI</div>
|
||||
<strong>脚本助手</strong>
|
||||
{textModels && textModels.length > 0 ? (
|
||||
<select
|
||||
className="setup-select"
|
||||
style={{ height: 24, fontSize: 12, padding: "0 6px", maxWidth: 150, marginLeft: 6 }}
|
||||
value={activeScriptModelId}
|
||||
onChange={(event) => setScriptModelId(event.target.value)}
|
||||
title="选择脚本生成模型(豆包 / GPT / Gemini)"
|
||||
>
|
||||
{textModels.map((m) => <option key={m.id} value={m.id}>{m.display_name || m.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="muted-2 mono" style={{ fontSize: "12px" }}>· {scriptModelName}</span>
|
||||
)}
|
||||
<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>
|
||||
@@ -1987,6 +2002,18 @@ export function PipelinePage(props: {
|
||||
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件" onClick={() => chatFileRef.current?.click()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</button>
|
||||
{/* 模型选择小按钮(放输入框下方,对齐 ChatGPT/Lovart) */}
|
||||
{textModels && textModels.length > 0 ? (
|
||||
<select
|
||||
className="setup-select"
|
||||
style={{ height: 26, fontSize: 12, padding: "0 6px", maxWidth: 150, marginLeft: 6 }}
|
||||
value={activeScriptModelId}
|
||||
onChange={(event) => setScriptModelId(event.target.value)}
|
||||
title="选择脚本生成模型(豆包 / GPT / Gemini)"
|
||||
>
|
||||
{textModels.map((m) => <option key={m.id} value={m.id}>{m.display_name || m.name}</option>)}
|
||||
</select>
|
||||
) : 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>
|
||||
|
||||
@@ -94,6 +94,7 @@ export type ScriptVersion = {
|
||||
speaker?: string;
|
||||
product_exposure?: string;
|
||||
entity_refs?: string[];
|
||||
dialogue?: Array<{ speaker: string | null; line: string }>;
|
||||
}>;
|
||||
// metadata 携带 hook/tone/entities(脚本 agent 产出),供结构化渲染与下游故事板 @图N
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user