@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user