添加艾特角色功能

This commit is contained in:
Azmat@qq.com
2026-09-17 18:31:29 +08:00
parent 5df038c635
commit abccf4393a
18 changed files with 2769 additions and 182 deletions
+274 -7
View File
@@ -79,6 +79,24 @@ _PERSON_VISUAL_RE = re.compile(
r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|"
r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)"
)
_CAST_RELATION_RE = re.compile(
r"(共同出镜|一起出镜|一同出镜|同框|都(?:要|会|需)?出镜|全部(?:角色)?出镜|"
r"(?:一起|一同)(?:拍|演)|全员出镜|(?:三个|三位|两位|两个|大家)(?:都要|一起)|"
r"轮流出镜|分别出镜|主讲|主角|主演|辅助出镜|配角|只(?:用|留|要)|单人出镜)"
)
_CAST_RELATION_AUTO_RE = re.compile(r"(你来定|你安排|你决定|随便|都行)")
_PRODUCT_REQUIRED_PRESETS = {
"痛点解决演示",
PLOT_TWIST_PRESET,
"达人口播种草",
"点击换款",
"多色商品换款",
"点触换款",
"商品拟人广告",
"商品图一键成片",
"前后对比实测",
"AI 宠物拟人",
}
def is_plot_twist_conversation(conversation: CreationConversation) -> bool:
@@ -351,6 +369,167 @@ def has_locked_person_reference(conversation: CreationConversation) -> bool:
)
def locked_person_references(conversation: CreationConversation) -> list[dict]:
"""返回去重后的已锁定人物,顺序与用户添加顺序一致。"""
people: list[dict] = []
seen: set[tuple[str, str]] = set()
for ref in conversation.pinned_refs or []:
if not isinstance(ref, dict) or ref.get("type") not in {"model", "character"} or not ref.get("id"):
continue
key = (str(ref.get("type")), str(ref.get("id")))
if key in seen:
continue
seen.add(key)
people.append(ref)
return people
def _cast_relation_signature(conversation: CreationConversation) -> str:
return "|".join(
f"{ref.get('type')}:{ref.get('id')}"
for ref in locked_person_references(conversation)
)
def cast_relation_options(conversation: CreationConversation) -> list[dict[str, str]]:
"""把当前已锁定人物直接变成一键选择项,不再让用户重复输入名称。"""
options = [{"value": "all_together", "label": "全部共同出镜"}]
for ref in locked_person_references(conversation):
name = str(ref.get("name") or "未命名角色").split(" · ")[0].strip() or "未命名角色"
options.append({
"value": f"lead:{ref.get('type')}:{ref.get('id')}",
"label": f"{name}主讲",
})
return options
def apply_cast_relation_choice(conversation: CreationConversation, choice: str) -> str:
"""保存多角色标签选择,并返回可直接喂给 Agent 的完整人物关系。"""
raw = str(choice or "").strip()
people = locked_person_references(conversation)
signature = _cast_relation_signature(conversation)
relation = ""
if raw == "all_together":
relation = "全部已锁定角色共同出镜"
elif raw.startswith("lead:"):
selected = next(
(
ref for ref in people
if raw == f"lead:{ref.get('type')}:{ref.get('id')}"
),
None,
)
if selected is not None:
name = str(selected.get("name") or "未命名角色").split(" · ")[0].strip() or "未命名角色"
relation = f"{name}作为主讲,其余已锁定角色辅助出镜"
if not relation:
return ""
memory = dict(conversation.memory or {})
memory["cast_relation"] = relation
memory["cast_relation_ref_signature"] = signature
memory.pop("cast_relation_pending_signature", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return relation
def _normalized_cast_relation(conversation: CreationConversation, user_text: str) -> str:
"""把简短回答补成不会丢角色的出镜安排。"""
answer = str(user_text or "").strip()
if _CAST_RELATION_AUTO_RE.search(answer):
return "由系统安排一位主讲,其余已锁定角色辅助出镜;全部角色都保留"
if re.search(
r"(共同出镜|一起出镜|一同出镜|同框|都(?:要|会|需)?出镜|全部(?:角色)?出镜|"
r"(?:一起|一同)(?:拍|演)|全员出镜|(?:三个|三位|两位|两个|大家)(?:都要|一起))",
answer,
):
return "全部已锁定角色共同出镜"
if not _CAST_RELATION_RE.search(answer):
# 闸门明确提示「有主讲就直接说角色名」;用户只回名字时,补全语义。
names = [str(ref.get("name") or "").strip() for ref in locked_person_references(conversation)]
aliases = {
alias
for name in names
for alias in (name, name.split(" · ")[0].strip())
if alias
}
if any(answer == alias or answer in alias or alias in answer for alias in aliases):
return f"{answer}作为主讲,其余已锁定角色辅助出镜"
return answer
def multi_character_relation_needs_clarification(
conversation: CreationConversation,
user_text: str = "",
) -> bool:
"""多人物已添加但关系不明时,先确认共同出镜还是主讲+辅助。
用人物 Ref 签名记录答案:后续新增/替换人物会重新确认,原班角色继续改稿时
不会反复追问。若当前消息本身已经写明关系,则直接采纳并放行。
"""
people = locked_person_references(conversation)
if len(people) < 2:
return False
signature = _cast_relation_signature(conversation)
memory = dict(conversation.memory or {})
if memory.get("cast_relation_ref_signature") == signature and memory.get("cast_relation"):
return False
text = str(user_text or "").strip()
if memory.get("cast_relation_pending_signature") == signature:
if not text:
return True
memory["cast_relation"] = _normalized_cast_relation(conversation, text)
memory["cast_relation_ref_signature"] = signature
memory.pop("cast_relation_pending_signature", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return False
if _CAST_RELATION_RE.search(text) or _CAST_RELATION_AUTO_RE.search(text):
memory["cast_relation"] = _normalized_cast_relation(conversation, text)
memory["cast_relation_ref_signature"] = signature
memory.pop("cast_relation_pending_signature", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return False
return True
def append_multi_character_relation_gate(conversation: CreationConversation) -> CreationMessage:
"""保留全部人物,只询问人物之间的出镜关系。"""
people = locked_person_references(conversation)
signature = _cast_relation_signature(conversation)
memory = dict(conversation.memory or {})
memory["cast_relation_pending_signature"] = signature
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
count = len(people)
question = (
f"这次已经添加了 {count} 位角色,我都会保留。"
"直接选择共同出镜,或点一位角色作为主讲,其余角色会辅助出镜。"
)
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=question,
payload={
"interaction": "chat",
"topic": "cast_relation",
"fields": [{
"key": "cast_relation",
"label": question,
"type": "single",
"required": True,
"options": cast_relation_options(conversation),
}],
"submitted": False,
"answers": {},
},
)
def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool:
"""需要真人/角色的视频在写策略前必须先锁定人物来源。"""
if conversation.mode != CreationConversation.Mode.VIDEO or has_locked_person_reference(conversation):
@@ -367,13 +546,44 @@ def video_needs_person_source(conversation: CreationConversation, user_text: str
return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent])))
def has_locked_product_reference(conversation: CreationConversation) -> bool:
return any(
isinstance(ref, dict) and ref.get("type") == "product" and ref.get("id")
for ref in (conversation.pinned_refs or [])
)
def creation_needs_product_source(conversation: CreationConversation, user_text: str = "") -> bool:
"""明确的商品类预设缺少结构化商品时,先索取而不是把普通素材猜成商品。
自由创作仍由 Agent 的 ask_user 规则和无输出兜底负责自然追问,避免抢在模型
已有澄清问题之前重复弹卡;预设创作则由平台确定性保证商品前置。
"""
if has_locked_product_reference(conversation):
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("product_source_resolved"):
return False
return conversation.preset in _PRODUCT_REQUIRED_PRESETS
def append_person_source_gate(conversation: CreationConversation) -> CreationMessage:
"""可视化的人物来源闸门;三个选项分别进文件、模特库和生图流程。"""
product_name = ""
for ref in (conversation.pinned_refs or []):
if isinstance(ref, dict) and ref.get("type") == "product":
product_name = str(ref.get("name") or "").split(" · ")[0].strip()
break
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。"
if product_name else
"先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。"
)
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。",
text=prompt_text,
payload={
"interaction": "person_source_gate",
"fields": [{
@@ -737,7 +947,7 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。
不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。
- 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。
- 结尾单列「全片一致性与约束」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
- 结尾单列「全片一致性与禁用项」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。
- 审核安全必须在第一次生成时完成,不能依赖提交前清洗。没有锁定人物素材时,人物只写「成年女性 / 成年男性 / 成年人」,不要自创精确年龄区间,不使用带幼态联想的称呼、音色或人设。服装写日常得体,默认平视、自然俯拍或尊重主体的正面构图,不强调身体局部。
- 最终 video_prompt 只使用正向安全描述。不要把平台风险类别、禁用词或用户原始高风险措辞逐项写进 Prompt,否定句、免责声明和「禁止出现某词」也不能照抄;先在内部把冲突改写成成年人之间积极、友善的日常互动,再输出改写后的可拍内容。
@@ -1242,6 +1452,7 @@ def apply_restart_intent(conversation: CreationConversation) -> int:
memory.pop("selling_point_ready", None)
memory.pop("selling_point_mode", None)
memory.pop("selling_point", None)
memory.pop("product_source_resolved", None)
memory.pop("pain_point_direction_ready", None)
memory.pop("pain_point_direction", None)
memory.pop("click_swap_ready", None)
@@ -1950,8 +2161,8 @@ def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str
return (
f"{prompt.strip()}\n\n"
f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}–{end} 秒的内容。"
"仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的同一位人物,"
"不得在本段重新设计、随机替换或改变其五官、发型、年龄、身形和服装。"
"仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的原有人物阵容与身份关系,"
"不得在本段重新设计、随机替换、遗漏人物或改变各自的五官、发型、年龄、身形和服装。"
"承接上一段的动作、商品、场景和光线,"
"为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。"
)
@@ -1970,11 +2181,21 @@ def apply_person_identity_guard(prompt: str, references: list[dict]) -> str:
if not indexes:
return prompt
labels = "、".join(f"参考图{index}" for index in indexes)
if len(indexes) == 1:
identity_rule = (
"整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、"
"肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔,"
"不得因镜头或光线变化而改变身份。"
)
else:
identity_rule = (
"这些参考图分别对应不同角色。整片及所有分段、远景、近景、转场都必须保持每位角色各自的"
"五官比例、脸型、发型、肤色、年龄、身形、手部特征和基础服装;人物不得互换、遗漏、"
"随机替换或合并成新面孔,也不得因镜头或光线变化而改变任何角色身份。"
)
return (
f"{prompt.strip()}\n\n【人物一致性硬约束】{labels}定义本片的固定出镜人物。"
"整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、"
"肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔,"
"不得因镜头或光线变化而改变身份。"
f"{identity_rule}"
)
@@ -2452,6 +2673,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"- ask_user 一次只问一件事。type=asset 会以类Agent自然追问轻量询问是否发送列表或由你推荐;其他类型显示普通聊天问题,让用户直接输入。",
"- 商品是谁、给谁看、什么调性 —— 这些缺了会直接影响成片,值得问。",
"- 让用户选商品/角色/模特/场景时,ask_user 必须用 type=asset 并填 asset_types。",
"- 用户已经添加多位角色并分别描述造型时,这些角色默认都属于本次创作,不得擅自删掉或要求用户从中三选一。"
" 如果人物关系没说清,只确认‘共同出镜’还是‘一位主讲、其余辅助’;只有用户明确说只用一位时才缩减角色。",
"- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 启动素材确认,不要强行出大卡片。",
"- 用户说改时长/模型/比例/分辨率但没给新值:立刻 ask_user,type=single 提供可识别的候选值;聊天里只显示问题,用户直接输入。回答后旧方案作废,必须按新参数重新 write_plan。",
"- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。",
@@ -2601,6 +2824,20 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"图上看不清或没附图时,必须问用户,禁止凭文件名猜测男女。"
)
memory = conversation.memory or {}
people = locked_person_references(conversation)
if len(people) > 1:
names = "、".join(str(ref.get("name") or "未命名角色") for ref in people)
relation = str(memory.get("cast_relation") or "").strip()
keep_rule = (
"按用户明确要求缩减出镜角色,但未出镜的人物素材仍保留在会话中。"
if re.search(r"(只用|只留|只要|单人出镜)", relation)
else "这些角色默认都必须保留。"
)
lines.append(f"\n【多角色锁定】本次已添加 {len(people)} 位角色:{names}。{keep_rule}")
if relation:
lines.append(f"已确认出镜安排:{relation}。后续策略、脚本和分镜必须遵守,不得再次询问同一问题。")
else:
lines.append("出镜关系尚未确认:只询问共同出镜还是主讲+辅助,不得问‘三位选哪一位’。")
if memory.get("summary"):
lines.append(f"\n【前情提要】{memory['summary']}")
artifacts = memory.get("artifacts") or []
@@ -3101,6 +3338,25 @@ def iter_creation_agent_events(
yield {"type": "done"}
return
# 商品类创作先锁定结构化商品。普通上传始终只是素材,不能靠图片内容猜成商品。
if creation_needs_product_source(conversation, text):
result, _stop = _dispatch_tool(
context,
"ask_user",
{"fields": [{
"key": "product",
"label": "这条内容要使用哪个商品?",
"type": "asset",
"required": True,
"asset_types": ["product"],
}]},
allow_pick=False,
)
for event in result.get("_events", []):
yield event
yield {"type": "done"}
return
# 点击换款的款式清单和顺序是脚本事实,不能让模型自行猜色号或把预设改成普通展示片。
if click_swap_needs_sequence(conversation):
question = append_click_swap_sequence_gate(conversation)
@@ -3122,6 +3378,17 @@ def iter_creation_agent_events(
yield {"type": "done"}
return
# 已经添加多位人物时,人物都属于本次 brief。只确认他们如何出镜,不能让
# 模型把「多角色 + 各自造型」误读成候选人列表并强迫用户三选一。
if context.is_video and multi_character_relation_needs_clarification(conversation, text):
question = append_multi_character_relation_gate(conversation)
set_video_gate_stage(conversation, "clarify")
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
# 明确要商品/角色/场景列表时,直接生成真实选择卡,绝不先让模型念出素材名称。
requested_card = requested_asset_card_from_context(conversation, text)
if requested_card: