添加艾特角色功能
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user