优化全能创作
Deploy dev / deploy (push) Successful in 39s

This commit is contained in:
Azmat@qq.com
2026-09-29 15:15:54 +08:00
parent 1130ecd4ba
commit c687ec4bbd
16 changed files with 632 additions and 258 deletions
+253 -115
View File
@@ -30,7 +30,6 @@ from django.db import transaction
from .creation import append_message, pin_refs
from .creation_presets import (
PLOT_TWIST_PRESET,
PLOT_TWIST_STORY_DEPTH_OPTIONS,
apply_image_preset_prompt,
apply_plot_twist_story_contract,
apply_plot_twist_direction_contract,
@@ -139,7 +138,7 @@ TRUNCATION_RETRY_INSTRUCTION = (
"上一次输出撞到长度上限被截断,工具参数不是完整 JSON,本次作废。"
"请立刻重新调用同一个工具:先写 usp / points / timeline,再写 video_prompt;"
"video_prompt 压到更紧凑的篇幅(长视频只写章节结构、每个 30 秒段 3–5 个关键镜头和段尾交接状态),"
"不要复述已写过的规则,不要输出工具调用以外的解释文字。"
"短剧仍要保留开端至结局的完整故事线和全时长分镜;不要复述已写过的规则,不要输出工具调用以外的解释文字。"
)
TRUNCATION_GIVE_UP_NOTICE = (
@@ -389,16 +388,19 @@ def active_plot_twist_story_depth(conversation: CreationConversation) -> str:
if from_duration and from_duration["value"] != "smart":
return str(from_duration["value"])
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return str(memory.get("plot_twist_story_depth") or "").strip()
depth = str(memory.get("plot_twist_story_depth") or "").strip()
return depth if depth in {"15s", "30s", "60s"} else ""
def set_plot_twist_story_depth(conversation: CreationConversation, value: str) -> dict | None:
"""卡片或自然语言选时长后,同步会话顶部参数与最终 Prompt 的故事结构。"""
"""同步会话时长与短剧结构;旧「智能推荐」入口归一为 60 秒。"""
if not is_plot_twist_conversation(conversation):
return None
depth = plot_twist_story_depth(value)
if depth is None:
return None
if depth["value"] == "smart":
depth = plot_twist_story_depth("60s")
memory = dict(conversation.memory or {})
params = dict(conversation.params or {})
memory["plot_twist_story_depth"] = depth["value"]
@@ -410,31 +412,59 @@ def set_plot_twist_story_depth(conversation: CreationConversation, value: str) -
return depth
def append_plot_twist_story_depth_question(conversation: CreationConversation) -> CreationMessage:
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="你希望这支剧情带货视频做到什么程度?",
payload={
"interaction": "plot_twist_story_depth",
"fields": [
{
"key": "story_depth",
"label": "故事深度选择",
"type": "single",
"required": True,
"options": [
{"value": item["value"], "label": f"{item['label']}|{item['summary']}"}
for item in PLOT_TWIST_STORY_DEPTH_OPTIONS
if item["value"] in {"15s", "30s", "60s"}
],
}
],
"submitted": False,
"answers": {},
},
)
_PLOT_TWIST_STORY_BEATS = ("开端", "冲突", "升级", "反转", "结局")
def plot_twist_storyline_issue(
video_prompt: str,
*,
timeline: list[dict] | None = None,
duration: int = 60,
) -> str:
"""短剧在展示给用户前检查完整故事骨架,防止只交出其中一小段。"""
beats = _PLOT_TWIST_STORY_BEATS if duration >= 45 else ("开端", "冲突", "反转", "结局")
minimum_beats = len(beats)
minimum_detail = 12 if duration >= 45 else 8
prompt = str(video_prompt or "")
if re.search(r"未完待续|下集(?:再|见|继续)|结尾留悬念", prompt):
return "短剧应在本片交代结局,不能停在未完待续或下集悬念。"
if "完整故事线" not in prompt:
return f"视频 Prompt 缺少【完整故事线】:先写{'、'.join(beats)},再展开逐镜脚本。"
last_position = -1
for beat in beats:
match = re.search(
rf"(?m)^[ \t]*(?:[-*][ \t]*)?{beat}(?:[ \t]*[((][^))]{{0,25}}[))])?[ \t]*[::][ \t]*(.+)$",
prompt,
)
if not match or match.start() <= last_position or len(re.sub(r"\s", "", match.group(1))) < minimum_detail:
return f"【完整故事线】中的「{beat}」需要按顺序写出具体人物行动、因果和结果,不能只有标题或一句概括。"
last_position = match.start()
prompt_spans = sorted({
(float(match.group(1)), float(match.group(2)))
for match in _SMART_DURATION_RE.finditer(prompt)
})
if len(prompt_spans) < minimum_beats or prompt_spans[0][0] > 1 or max(end for _, end in prompt_spans) < duration - 2:
return f"视频 Prompt 的分镜须从 0 秒连续写到 {duration} 秒结局,不能只描述故事中间的一场戏。"
covered_until = prompt_spans[0][1]
for start, end in prompt_spans[1:]:
if start - covered_until > 2:
return "视频 Prompt 的分镜中间有时间空档,请补全人物行动与因果承接。"
covered_until = max(covered_until, end)
if timeline is not None:
spans: list[tuple[float, float]] = []
for item in timeline:
try:
start, end = float(item["start"]), float(item["end"])
except (KeyError, TypeError, ValueError):
continue
if end > start:
spans.append((start, end))
spans.sort()
if len(spans) < minimum_beats or not spans or spans[0][0] > 1 or spans[-1][1] < duration - 2:
return f"方案时间轴须从故事起因覆盖到 {duration} 秒结局,至少分 {minimum_beats} 段承接,不能只写中间片段。"
if any(next_start - end > 2 for (_, end), (next_start, _) in zip(spans, spans[1:])):
return "方案时间轴中间有故事断档;请补全前后动作和因果承接。"
return ""
def _plot_twist_direction_fallback(conversation: CreationConversation) -> list[dict]:
@@ -1162,7 +1192,8 @@ def append_person_source_gate(conversation: CreationConversation, extra_text: st
product_name = str(memory.get("product_name") or memory.get("product_brand_and_name") or "").strip()
is_pet = is_pet_preset(conversation.preset)
missing_cast = 1
role_identity = ""
default_person_prompt = ""
if is_pet:
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪只宠物角色出镜?选定后,所有镜头和分段都会锁定同一只宠物形象。"
@@ -1174,21 +1205,21 @@ def append_person_source_gate(conversation: CreationConversation, extra_text: st
else:
cast_needed = infer_needed_cast_count(conversation, extra_text)
cast_have = len(locked_person_references(conversation))
missing_cast = max(1, cast_needed - cast_have)
cast_index = min(cast_needed, cast_have + 1)
role_identity, default_person_prompt = cast_role_profile(conversation, cast_index, cast_needed)
if cast_needed > 1:
prompt_text = (
f"商品已选定【{product_name}】。这条视频需要 {cast_needed} 位出镜人物,"
f"当前补全第 {cast_index}/{cast_needed} 位;所有镜头和分段都会按角色图锁脸。"
f"先补第 {cast_index}/{cast_needed} 位:{role_identity}。确认这位角色后再准备下一位;所有镜头和分段都会按角色图锁脸。"
if product_name else
f"这条视频需要 {cast_needed} 位出镜人物,当前补全第 {cast_index}/{cast_needed} 位。"
"所有镜头和分段都会按角色图锁脸,避免前后形象漂移。"
f"这条视频需要 {cast_needed} 位出镜人物。先补第 {cast_index}/{cast_needed} 位:{role_identity}。"
"确认这位角色后再准备下一位,避免前后形象漂移。"
)
else:
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。"
f"商品已选定【{product_name}】。本片出镜角色:{role_identity}。请选择角色来源;选定后,所有镜头和分段都会锁定同一位人物。"
if product_name else
"先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。"
f"本片出镜角色:{role_identity}。请先确定角色来源;选定后,所有镜头和分段都会锁定同一位人物。"
)
field_label = "选择人物来源"
library_label = "从模特库选择"
@@ -1203,7 +1234,9 @@ def append_person_source_gate(conversation: CreationConversation, extra_text: st
"is_pet": is_pet,
"cast_needed": 1 if is_pet else infer_needed_cast_count(conversation, extra_text),
"cast_have": len(locked_person_references(conversation)),
"estimated_credits": estimate_role_image_credits(conversation, missing_cast),
"role_identity": role_identity,
"default_person_prompt": default_person_prompt,
"estimated_credits": estimate_role_image_credits(conversation, 1),
"fields": [{
"key": "person_source",
"label": field_label,
@@ -1418,14 +1451,6 @@ def infer_needed_cast_count(conversation: CreationConversation, extra_text: str
mapping = {"1": 1, "2": 2, "3": 3, "4": 4, "一": 1, "二": 2, "三": 3, "四": 4}
needed = max(needed, max(mapping.get(item, 1) for item in role_labels))
split_roles = [
part.strip()
for part in re.split(r"(?:^|\n)\s*(?:角色|人物)\s*[1-4一二三四][::、..\s]", blob)
if part.strip()
]
if len(split_roles) >= 2:
needed = max(needed, min(4, len(split_roles)))
return max(1, min(4, needed))
@@ -1508,6 +1533,54 @@ def _shared_cast_gender_from_text(text: str, total: int) -> str:
return ""
_ROLE_IDENTITIES = (
"母亲", "妈妈", "女儿", "父亲", "爸爸", "儿子", "姐姐", "妹妹", "哥哥", "弟弟",
"女主", "男主", "闺蜜", "空乘", "旅客", "顾客", "店员", "同事", "朋友", "达人", "主播",
)
def cast_role_profile(conversation: CreationConversation, index: int, total: int) -> tuple[str, str]:
"""从已确认的视频 Prompt 提取当前角色身份,并给单张定妆图预填可编辑的外观。"""
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
script = str(memory.get("pending_video_prompt") or "")
context = script or _conversation_cast_text_blob(conversation)
numbered = re.findall(
r"(?:角色|人物)\s*([1-4一二三四])\s*[::、..]\s*([^\n;;。]{2,160})",
script,
)
number_map = {"1": 1, "2": 2, "3": 3, "4": 4, "一": 1, "二": 2, "三": 3, "四": 4}
clause = next((body.strip() for label, body in numbered if number_map.get(label) == index), "")
relation = next((pair for word, pair in (
("母女", ("母亲", "女儿")), ("父女", ("父亲", "女儿")),
("母子", ("母亲", "儿子")), ("父子", ("父亲", "儿子")),
("姐妹", ("姐姐", "妹妹")), ("兄弟", ("哥哥", "弟弟")),
("男女主", ("男主", "女主")),
) if word in context), ())
explicit_identity = next((word for word in _ROLE_IDENTITIES if word in clause), "")
if explicit_identity:
identity = explicit_identity
elif relation and index <= len(relation):
identity = relation[index - 1]
else:
gender = _cast_gender_from_text(clause) or _shared_cast_gender_from_text(context, total)
identity = f"第{index}位成年{gender}角色" if gender else ("出镜主角" if total == 1 else f"第{index}位出镜人物")
gender = _cast_gender_from_text(clause) or _cast_gender_from_text(identity) or _shared_cast_gender_from_text(context, total)
# 只取角色外观,不把动作、商品或分镜内容带入定妆图。
appearance = re.split(r"(?:在|展示|拿着|手持|使用|进入|走向|镜头|商品|产品|画面|场景)", clause, maxsplit=1)[0]
appearance = _character_only_appearance_text(appearance).strip(",,。;; ")
if appearance and len(appearance) <= 80:
default = appearance
else:
default = f"成年{gender},{identity}" if gender else f"成年人物,{identity}"
if identity not in default:
default = f"{identity},{default}"
if gender and gender not in default and not re.search(_FEMALE_CAST_WORDS if gender == "女性" else _MALE_CAST_WORDS, default):
default = f"成年{gender},{default}"
default += ",写实角色形象,五官和发型清晰,服装符合角色身份"
return identity, default[:500]
def _cast_gender_requirements(
conversation: CreationConversation,
role_briefs: list[str],
@@ -1579,10 +1652,14 @@ def _build_single_person_reference_prompt(
if gender_requirement else ""
)
prompt = (
f"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图({role_tag}出镜角色)。"
f"只出现一位成年人物。{gender_instruction}正面或轻微三分之四角度,中近景,表情自然,"
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
f"为短视频生成一张横向角色设定图({role_tag}出镜角色),所有视角必须是同一位成年人物。"
f"{gender_instruction}严格按以下版式排布:左侧占画面约三分之二,横向并排展示这个角色的"
"全身正面、全身侧面、全身背面三视图;每一视图都从头顶到鞋底完整入画,双脚清晰,"
"不要截断头部、腿部或鞋子。右侧占画面约三分之一,上方是脸部正面清晰特写,"
"下方是脸部严格侧面清晰特写。五个视角的脸型、五官、发型、肤色、身材、"
"年龄和服装必须完全一致,不能像五个不同的人。站姿自然,服装与鞋子细节明确,"
"写实摄影,均匀柔和布光,纯色简约背景。除这位角色的多视角展示外不要其他人物;"
"不要文字、标签、边框、水印、遮脸道具或夸张滤镜。"
f"{no_product}"
)
if appearance_only:
@@ -1607,11 +1684,11 @@ def submit_generated_person_reference(
appearance_prompt: str = "",
cast_count: int | None = None,
) -> list:
"""生成人物/宠物角色定妆参考;多人物视频会一次生成多张,完成后再建模特并锁定。
"""每次只生成当前一位人物/宠物;确认后才进入下一位。
定妆图 prompt 只写角色外观,不写入商品名/卖点/用户创作简述,也不带商品参考图,
否则出图模型常会把商品画进角色照。
返回 GENERATING 消息列表(单人时长度为 1)。
返回仅含当前角色的一条 GENERATING 消息。
"""
from .services import enqueue_standalone_images
@@ -1627,22 +1704,25 @@ def submit_generated_person_reference(
context_brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700]
raw_appearance = (appearance_prompt or "").strip()[:500]
total = cast_count if cast_count is not None else (
infer_needed_cast_count(conversation, raw_appearance) - len(locked_person_references(conversation))
)
cast_have = len(locked_person_references(conversation))
total = cast_count if cast_count is not None else infer_needed_cast_count(conversation, raw_appearance)
total = max(1, min(4, int(total or 1)))
if is_pet_preset(conversation.preset):
total = 1
role_briefs = build_cast_role_briefs(total, raw_appearance)
gender_requirements = _cast_gender_requirements(conversation, role_briefs, raw_appearance)
index = min(total, cast_have + 1)
role_identity, default_prompt = cast_role_profile(conversation, index, total) if not is_pet_preset(conversation.preset) else ("宠物主角", "")
brief = _character_only_appearance_text(raw_appearance or default_prompt)
gender_requirement = _cast_gender_requirements(conversation, [brief] * total, brief)[index - 1]
memory = dict(conversation.memory or {})
memory["person_source"] = "platform_generate"
memory["person_source_pending"] = True
memory["person_prompt"] = appearance_prompt
memory["person_prompt"] = raw_appearance or default_prompt
memory["person_cast_total"] = total
memory["person_cast_pending"] = total
memory["person_cast_model_ids"] = []
memory["person_cast_pending"] = 1
memory["person_cast_index"] = index
memory["person_cast_role_identity"] = role_identity
memory.setdefault("person_cast_model_ids", [])
memory.pop("person_confirm_pending", None)
memory.pop("person_source_ready", None)
memory.pop("person_model_id", None)
@@ -1651,44 +1731,42 @@ def submit_generated_person_reference(
conversation.agent_status = CreationConversation.AgentStatus.IDLE
conversation.save(update_fields=["memory", "status", "agent_status", "updated_at"])
messages = []
for index, brief in enumerate(role_briefs, start=1):
prompt, label, model_name = _build_single_person_reference_prompt(
conversation=conversation,
appearance_only=brief,
context_brief=context_brief,
cast_index=index,
cast_total=total,
gender_requirement=gender_requirements[index - 1],
)
tasks = enqueue_standalone_images(
team=conversation.team,
user=user,
prompt=prompt,
mode="model",
count=1,
ratio="portrait",
feature="omni_create",
)
task = tasks[0]
messages.append(
append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.GENERATING,
payload={
"task_id": str(task.id),
"kind": "person_reference",
"prompt": prompt,
"label": label,
"cast_index": index,
"cast_total": total,
"cast_model_name": model_name,
},
task=task,
)
)
return messages
prompt, label, model_name = _build_single_person_reference_prompt(
conversation=conversation,
appearance_only=brief,
context_brief=context_brief,
cast_index=index,
cast_total=total,
gender_requirement=gender_requirement,
)
image_ratio = "portrait" if is_pet_preset(conversation.preset) else "16:9"
tasks = enqueue_standalone_images(
team=conversation.team,
user=user,
prompt=prompt,
mode="model",
count=1,
ratio=image_ratio,
feature="omni_create",
)
task = tasks[0]
return [append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.GENERATING,
payload={
"task_id": str(task.id),
"kind": "person_reference",
"ratio": image_ratio,
"prompt": prompt,
"label": label,
"cast_index": index,
"cast_total": total,
"cast_role_identity": role_identity,
"cast_model_name": model_name,
},
task=task,
)]
def insufficient_cast_refs_message(conversation: CreationConversation, prompt: str = "") -> str:
@@ -1702,7 +1780,7 @@ def insufficient_cast_refs_message(conversation: CreationConversation, prompt: s
return ""
return (
f"这是多人物视频,需要 {needed} 张角色定妆图锁定前后形象,当前只有 {have} 张。"
"请先用「平台帮忙生成」一次生成多位角色,或从模特库/本地补齐后再出片。"
"请先逐位使用「平台帮忙生成」,或从模特库/本地补齐后再出片。"
)
@@ -2012,9 +2090,10 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
色彩与材质系统:写出主色、材质、皮肤/产品/环境的可见质感。
打光规则:光源方向、软硬、色温、人物和产品分别如何受光。
剪辑节奏:列出时间段与 Hook → 证据/体验 → 转化收束的推进逻辑。
剧情反转带货例外:上述广告节奏让位于完整故事的起因→行动→受阻→升级→反转→结果;商品证据嵌入剧情因果,结局之后才自然收束,不要把 60 秒写成某一场戏的片段。
声音方向:人声身份、语气、语速、环境声/拟音、背景音乐的进入和收束;人声原文必须分配到对应分镜。
场景:逐一写清可见地点、前中后景、环境道具与景深。
主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。
主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。多人物时先分行写「角色1:身份、性别、成年年龄段、发型与服装」「角色2:…」,每位角色单独一行,便于逐位生成定妆图;角色外观不得与剧情身份矛盾。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。
各个分镜的具体内容:按时间顺序逐段展开。
分镜不能省略细节:每一段都必须严格用下面四行写完,不得只写一句画面描述:
@@ -3087,6 +3166,9 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"""给模型看的工具清单。图片会话不暴露 generate_video,反之亦然 ——
会话 mode 是定死的(契约 §0),把不该用的工具摆出来只会诱导模型走错路。
allow_plan=False 时隐藏 write_strategy / write_plan / generate_image,闲聊用不出来。"""
plot_full_length = video_duration(context.conversation.params or {}) >= 45
plot_beats = "开端、冲突、升级、反转、结局" if plot_full_length else "开端、冲突、反转、结局"
plot_stage_count = 5 if plot_full_length else 4
tools = [
{
"type": "function",
@@ -3166,7 +3248,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"function": {
"name": "present_story_directions",
"description": (
"剧情反转带货预设在用户选定故事深度后,先调用此工具展示 3 个可点击的剧情方向。"
"剧情反转带货预设默认按 60 秒完整短剧展开,先调用此工具展示 3 个可点击的剧情方向。"
"每条都必须有不同的冲突、商品承担的实际作用、反转和情绪;不能只说‘我准备了三个方向’。"
"用户点击其一后才可 write_strategy;本工具调用后必须停下来等待选择。"
),
@@ -3236,8 +3318,12 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、"
"声音/灯光/场景/参考素材锁定、可执行镜头和一致性收束,不要只写大纲。"
)
+
"第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
+ (
f"剧情反转带货须先写【完整故事线】的{plot_beats},"
f"再按至少{plot_stage_count}段连续时间轴写完整的一集;结尾解决开头的问题,不能只交中间一场戏。"
if is_plot_twist_conversation(context.conversation) else ""
)
+ "第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
"先完成内部 write_strategy,再调它。修改架构时必须沿用上一版,只改用户指出的部分,未受影响的时间段原样保留。"
),
"parameters": {
@@ -3283,6 +3369,11 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"60 秒以内每一镜包含拍法/画面内容/主体或产品露出/声音;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。"
"已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字。"
"人物必须明确为成年人且构图得体;只写正向可拍内容,不列风险词或禁用词。"
+ (
f"剧情预设另须先逐行写【完整故事线】的{plot_beats},"
"每段有具体因果;分镜连续覆盖全片,结尾完成角色目标或关系的结果。"
if is_plot_twist_conversation(context.conversation) else ""
)
),
},
},
@@ -3305,6 +3396,10 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
else
"video_prompt 必须是系统规定的制作级完整文件,不能只把旧 Prompt 缩写成几行分镜。"
)
+ (
"剧情预设必须保留开端到结局的完整故事线和全片分镜,不可只写故事中的一个片段。"
if is_plot_twist_conversation(context.conversation) else ""
)
),
"parameters": {
"type": "object",
@@ -4194,6 +4289,23 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
]
if context.is_video:
lines.extend(["", _OMNI_VIDEO_PROMPT_RULES.strip()])
if is_plot_twist_conversation(conversation):
plot_full_length = video_duration(conversation.params or {}) >= 45
plot_beat_lines = (
"「开端:」「冲突:」「升级:」「反转:」「结局:」"
if plot_full_length else "「开端:」「冲突:」「反转:」「结局:」"
)
plot_stage_count = 5 if plot_full_length else 4
lines.append(
"【短剧完整故事线·优先于通用广告节奏】默认 60 秒是一集独立完整短剧;较短时长也必须讲完故事。"
"write_plan 的 video_prompt 在技术规则和分镜前必须有【完整故事线】,"
f"按顺序逐行写{plot_beat_lines},"
"每行写具体人物、目标、行动与可见结果,不是几个空标题。"
"从第一秒的起因到最后一秒的结局覆盖全片;前 3 秒闪前后也要回到起因。"
f"方案时间轴至少{plot_stage_count}段连续覆盖全时长,逐镜从故事线展开,镜头之间明确‘因为前一动作,所以发生下一动作’。"
"结尾先完成冲突与人物关系的回收,再自然表达商品价值,不留未完待续。"
"write_prompt 修改时仍保留完整故事线和全时长分镜,不能只截取中间一场戏。"
)
lines.extend([
"",
"【视频创作工作原则】",
@@ -4311,7 +4423,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
if is_plot_twist_conversation(conversation):
depth = active_plot_twist_story_depth(conversation)
if not depth:
lines.append("剧情反转带货尚未选择故事深度:必须先调用 ask_user 让用户选 15 秒、30 秒、60 秒或智能推荐;此时禁止给剧情方向、策略或方案。")
lines.append("剧情反转带货默认 60 秒完整短剧;不要追问开场时长,先按 60 秒核对商品,再展示剧情方向。")
else:
lines.append(plot_twist_story_contract(depth))
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
@@ -4964,18 +5076,10 @@ def iter_creation_agent_events(
yield {"type": "done"}
return
# 剧情带货的第一个必经步骤永远是选时长;之后才核对商品与剧情方向。
# 剧情带货新会话默认 60 秒,不再让用户先选 15/30/60 秒;
# 历史会话明确选过的时长仍保留,避免重进后改写已确认的结构。
if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation):
explicit_depth = plot_twist_story_depth(text)
if explicit_depth is not None and explicit_depth["value"] != "smart":
set_plot_twist_story_depth(conversation, explicit_depth["value"])
else:
question = append_plot_twist_story_depth_question(conversation)
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
set_plot_twist_story_depth(conversation, "60s")
# 0. 本地上传商品图优先确认品牌与具体品名
if product_info_needs_confirmation(conversation, text):
@@ -5761,6 +5865,25 @@ def _dispatch_tool(
fields = _coerce_fields(args.get("fields"))
if not fields:
return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False
# 剧情方向不能退化为普通单选:ask_user 的 options 只有标题,
# 用户看不到冲突、商品作用与反转。统一改用有完整内容的方向卡。
if (
context.is_video
and is_plot_twist_conversation(context.conversation)
and active_plot_twist_story_depth(context.conversation)
and not plot_twist_selected_direction(context.conversation)
and any(
"story_direction" in str(field.get("key") or "").lower()
or "plot_direction" in str(field.get("key") or "").lower()
or re.search(r"(?:剧情|故事).{0,6}方向", str(field.get("label") or ""))
for field in fields
)
):
return _dispatch_tool(
context,
"present_story_directions",
{"directions": _plot_twist_direction_fallback(context.conversation)},
)
# 临时隐藏 >60 秒长视频:模型自拟的时长选项也裁掉
for field in fields:
if not isinstance(field, dict):
@@ -5896,6 +6019,15 @@ def _dispatch_tool(
video_prompt = str(args.get("video_prompt") or "").strip()
if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
card = _coerce_plan_card_args(args if isinstance(args, dict) else {})
if is_plot_twist_conversation(context.conversation):
issue = plot_twist_storyline_issue(
video_prompt,
timeline=card["timeline"],
duration=video_duration(context.conversation.params or {}),
)
if issue:
return {"payload": {"error": f"短剧不是故事中的一小段。{issue}请重写完整方案和 Prompt。"}}, False
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
@@ -5932,7 +6064,6 @@ def _dispatch_tool(
"error": "剧情反转带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。"
}
}, False
card = _coerce_plan_card_args(args if isinstance(args, dict) else {})
if not card["usp"] or not card["points"]:
return {
"payload": {
@@ -6031,6 +6162,13 @@ def _dispatch_tool(
video_prompt = get_pending_video_prompt(context.conversation)
if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
if is_plot_twist_conversation(context.conversation):
issue = plot_twist_storyline_issue(
video_prompt,
duration=video_duration(context.conversation.params or {}),
)
if issue:
return {"payload": {"error": f"短剧出片指令需要完整故事。{issue}请保留整条故事线后重写。"}}, False
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")