解决已发现问题

This commit is contained in:
Azmat@qq.com
2026-09-17 15:00:15 +08:00
parent f3c49b36be
commit 5df038c635
24 changed files with 1913 additions and 94 deletions
+616 -21
View File
@@ -33,6 +33,7 @@ from .creation_presets import (
apply_image_preset_prompt,
apply_plot_twist_story_contract,
apply_video_preset_prompt,
is_click_swap_preset,
plot_twist_story_depth,
plot_twist_story_contract,
preset_guidance,
@@ -41,7 +42,13 @@ from .creation_presets import (
)
from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions
from .models import CreationConversation, CreationMessage, ModelConfig
from .services import build_provider, get_default_model, get_seed_text_model, resolve_text_model
from .services import (
build_provider,
enforce_no_embedded_captions,
get_default_model,
get_seed_text_model,
resolve_text_model,
)
logger = logging.getLogger(__name__)
@@ -54,17 +61,89 @@ MAX_BILLED_GENERATIONS = 1
# 视频闸门阶段(落在 conversation.memory.stage;resume 靠它)
# clarify → strategy → plan → prompt → confirm → done
VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done")
PAIN_POINT_PRESET = "痛点解决演示"
PAIN_POINT_DIRECTION_KEY = "pain_point_direction"
_STEP_CONFIRM_LABELS = {
"strategy": "创作策略已写好。确认后继续写方案;要改就点「我想改」或直接说改哪里。",
"plan": "视频方案已写好。确认后我会整理出片细节,并带你确认生成参数;要改就点「我想改」或直接说改哪里。",
"prompt": "出片指令已整理好。确认后核对参数并生成;要改就点「我想改」或直接说改哪里。",
}
_PERSON_SOURCE_PRESETS = {
"痛点解决演示",
PLOT_TWIST_PRESET,
"达人口播种草",
"鱼眼换装",
}
_PERSON_VISUAL_RE = re.compile(
r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|"
r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)"
)
def is_plot_twist_conversation(conversation: CreationConversation) -> bool:
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET
def is_pain_point_conversation(conversation: CreationConversation) -> bool:
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PAIN_POINT_PRESET
def is_pain_point_direction_payload(payload: dict) -> bool:
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
return bool(fields and str(fields[0].get("key") or "") == PAIN_POINT_DIRECTION_KEY)
def apply_pain_point_direction(
conversation: CreationConversation,
payload: dict,
choice: str,
) -> str:
"""把已选方向直接作为本轮核心痛点/卖点,后续不再重复追问核心卖点。"""
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
options = (fields[0].get("options") or []) if fields else []
selected = next(
(
item for item in options
if isinstance(item, dict)
and str(item.get("value") or "") == str(choice or "")
),
None,
)
direction = str((selected or {}).get("label") or choice or "").strip()
memory = dict(conversation.memory or {})
memory["pain_point_direction_ready"] = True
memory["pain_point_direction"] = direction
memory["selling_point_ready"] = True
memory["selling_point_mode"] = "manual"
memory["selling_point"] = direction
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return (
f"商家已选择痛点方向:【{direction}】。这个选择同时就是本轮要突出的核心痛点与核心卖点。"
"现在只调用 write_strategy 写创作策略,并让痛点、正常使用过程和可见结果都围绕它展开;"
"不要再询问核心卖点,不要复述选择,不要直接写方案或出片。"
)
def pain_point_direction_options_from_text(text: str) -> list[dict[str, str]]:
"""模型偶尔只输出三条列表而忘记 ask_user;把列表确定性转成可点击选项。"""
items: list[str] = []
for line in str(text or "").splitlines():
match = re.match(r"^\s*(?:[-*+•]|[1-3][.、.)])\s*(.+?)\s*$", line)
if not match:
continue
label = re.sub(r"\*\*", "", match.group(1)).strip()
if label and label not in items:
items.append(label)
if len(items) != 3:
return []
return [
{"value": f"direction_{index}", "label": label}
for index, label in enumerate(items, start=1)
]
def active_plot_twist_story_depth(conversation: CreationConversation) -> str:
"""时长是最终事实来源;用户中途改时长后,故事结构必须随之切换。"""
from_duration = plot_twist_story_depth(str((conversation.params or {}).get("duration") or ""))
@@ -260,6 +339,146 @@ def append_selling_point_gate(conversation: CreationConversation) -> CreationMes
)
def has_locked_person_reference(conversation: CreationConversation) -> bool:
"""人物一致性的前提是会话里有可解析的人物 Ref。
本地上传人物图由前端标记为 character,模特库则是 model;普通 asset
不能被默认当人,否则商品图/场景图会误跳过这个闸门。
"""
return any(
isinstance(ref, dict) and ref.get("type") in {"model", "character"} and ref.get("id")
for ref in (conversation.pinned_refs or [])
)
def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool:
"""需要真人/角色的视频在写策略前必须先锁定人物来源。"""
if conversation.mode != CreationConversation.Mode.VIDEO or has_locked_person_reference(conversation):
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("person_source_ready") or memory.get("person_source_pending"):
return False
if conversation.preset in _PERSON_SOURCE_PRESETS:
return True
recent = list(
conversation.messages.order_by("-seq").values_list("text", flat=True)[:12]
)
pending_prompt = str(memory.get("pending_video_prompt") or "")
return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent])))
def append_person_source_gate(conversation: CreationConversation) -> CreationMessage:
"""可视化的人物来源闸门;三个选项分别进文件、模特库和生图流程。"""
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。",
payload={
"interaction": "person_source_gate",
"fields": [{
"key": "person_source",
"label": "选择人物来源",
"type": "single",
"required": True,
"options": [
{"value": "local_upload", "label": "本地上传"},
{"value": "model_library", "label": "从模特库选择"},
{"value": "platform_generate", "label": "平台帮忙生成"},
],
}],
"submitted": False,
"answers": {},
},
)
def click_swap_sequence(conversation: CreationConversation) -> str:
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return str(memory.get("click_swap_sequence") or "").strip()
def click_swap_needs_sequence(conversation: CreationConversation) -> bool:
"""点击换款只有在商家明确款式和顺序后才能写脚本。"""
if conversation.mode != CreationConversation.Mode.VIDEO:
return False
if not is_click_swap_preset(conversation.preset):
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return not bool(memory.get("click_swap_ready") and click_swap_sequence(conversation))
def append_click_swap_sequence_gate(conversation: CreationConversation) -> CreationMessage:
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先确认要切换的款式和展示顺序。后续每次手指点击都会严格按这个顺序原位换款。",
payload={
"interaction": "click_swap_sku_gate",
"fields": [{
"key": "sku_sequence",
"label": "款式与切换顺序",
"type": "text",
"required": True,
"placeholder": "例如:黑色 → 白色 → 樱花粉",
}],
"submitted": False,
"answers": {},
},
)
def submit_generated_person_reference(*, conversation: CreationConversation, user) -> CreationMessage:
"""先生成独立人物定妆参考,完成后再由 creation.py 自动建模特并锁定。"""
from .services import enqueue_standalone_images
recent_user = list(
conversation.messages.filter(role=CreationMessage.Role.USER)
.order_by("-seq").values_list("text", flat=True)[:6]
)
brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700]
prompt = (
"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图。"
"只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然,"
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
)
if conversation.preset:
prompt += f" 适配视频预设:{conversation.preset}。"
if brief:
prompt += f" 参考用户需求:{brief}"
tasks = enqueue_standalone_images(
team=conversation.team,
user=user,
prompt=prompt,
mode="model",
count=1,
ratio="portrait",
feature="omni_create",
)
task = tasks[0]
memory = dict(conversation.memory or {})
memory["person_source"] = "platform_generate"
memory["person_source_pending"] = True
conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING
conversation.agent_status = CreationConversation.AgentStatus.IDLE
conversation.save(update_fields=["memory", "status", "agent_status", "updated_at"])
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.GENERATING,
payload={
"task_id": str(task.id),
"kind": "person_reference",
"prompt": prompt,
"label": "正在生成人物参考",
},
task=task,
)
def _step_confirm_payload(step: str) -> dict:
return {
"interaction": "step_confirm",
@@ -472,7 +691,9 @@ IMAGE_MODEL_BY_LABEL = {
# 不能把每条片都悄悄压成 15 秒。
SMART_DURATION = 15
_SMART_DURATION_RE = re.compile(
r"(?<!\d)(\d{1,2}(?:\.\d+)?)\s*(?:-|—|–|~|至|到)\s*(\d{1,2}(?:\.\d+)?)\s*秒?"
# 结尾必须明确带「秒/s」。原来的「秒?」会把“18–22岁”误当成 22 秒。
r"(?<!\d)(\d{1,2}(?:\.\d+)?)\s*(?:-|—|–|~|至|到)\s*(\d{1,2}(?:\.\d+)?)\s*(?:秒|s)",
re.IGNORECASE,
)
_TOTAL_DURATION_RE = re.compile(
r"(?:总时长|成片时长|时长)\s*[::]?\s*(\d{1,2}(?:\.\d+)?)\s*秒",
@@ -516,9 +737,11 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。
不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。
- 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。
- 结尾单列「全片一致性与禁用项」:重申角色、商品、场景、光线、服装/材质的连续性,以及本片最重要的画面禁用项。
- 结尾单列「全片一致性与约束」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。
- 平台安全优先于戏剧冲突:不要撰写或照抄暴力伤害、血腥、裸体或性暗示、违法犯罪、危险挑战、政治敏感、仇恨歧视、真实名人/IP 模仿等内容。若用户原意涉及上述内容,保留原来的情绪转折和商品卖点,改成成年人之间安全、非暴力、无违法的日常误会、压力或竞争,并采用原创人物与场景。儿童只能出现在安全的日常家庭场景,绝不参与危险、成人或营销煽动情节。
- 审核安全必须在第一次生成时完成,不能依赖提交前清洗。没有锁定人物素材时,人物只写「成年女性 / 成年男性 / 成年人」,不要自创精确年龄区间,不使用带幼态联想的称呼、音色或人设。服装写日常得体,默认平视、自然俯拍或尊重主体的正面构图,不强调身体局部。
- 最终 video_prompt 只使用正向安全描述。不要把平台风险类别、禁用词或用户原始高风险措辞逐项写进 Prompt,否定句、免责声明和「禁止出现某词」也不能照抄;先在内部把冲突改写成成年人之间积极、友善的日常互动,再输出改写后的可拍内容。
- 平台安全优先于戏剧冲突:若原意不适合直接出片,只保留情绪转折和真实商品卖点,改成成年人之间的日常误会、压力或良性竞争,并采用原创人物与场景;不要在输出中复述被替换掉的原情节。
- 不写医疗诊断、治愈/根治、绝对效果、虚构权威背书或夸大功效;把卖点改为真实可见的使用动作、材质细节和日常体验。最终 video_prompt 必须是可直接提交审核和出片的安全版本。
"""
@@ -553,12 +776,10 @@ PRODUCT_REALITY_GUARD = (
# 这一层不是替代平台审核,而是在脚本落成最终 video_prompt 前先把最容易被视频模型
# 拦截的明确高风险表述改成等价的安全叙事。模型仍会收到下方完整约束,避免只靠关键词替换。
VIDEO_PLATFORM_SAFETY_GUARD = (
"【平台安全出片约束·最高优先级】全片仅呈现安全、合法、适合公开传播的原创商业内容。"
"不出现或暗示暴力伤害、血腥、自残、裸体、性内容、违法犯罪、赌博、毒品、武器、危险挑战、"
"政治敏感、仇恨歧视、真实名人或受保护 IP 模仿。若原剧情有冲突,只用成年人之间安全、"
"非暴力的日常误会、压力或竞争来表达,并以沟通或轻松反转收束。儿童仅可出现在安全的日常家庭场景,"
"不得参与危险、成人或煽动性情节。商品功效只写已知事实和可见使用体验,不作医疗、治愈、"
"绝对化或虚构权威承诺。"
"【平台安全出片约束·最高优先级】全片采用健康、友善、合法且适合公开传播的原创商业表达。"
"出镜人物均明确为二十二岁以上成年人,着装与镜头语言自然得体,构图保持尊重。"
"所有情节通过日常互动、积极沟通和轻松表达推进,并以正向结果收束。"
"商品只呈现已知事实、正常用途和可观察的使用体验,所有描述保持客观克制。"
)
_VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
(
@@ -566,7 +787,7 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
r"暴打|殴打|群殴|互殴|动手伤人|打伤|捅伤|刺伤|砍伤|见血|鲜血(?:直流)?|血泊|"
r"杀人|杀死|自杀|自残|割腕|跳楼|爆炸|绑架|虐待"
),
"强烈情绪冲突以安全、非暴力方式化解",
"强烈情绪冲突通过沟通与日常误会化解",
),
(
re.compile(r"裸体|裸露|色情|性爱|性行为|性暗示|挑逗|床戏"),
@@ -574,7 +795,7 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
),
(
re.compile(r"吸毒|毒品交易|贩毒|赌博|赌钱|诈骗|抢劫|偷窃|枪战|持枪|开枪"),
"不涉及违法或危险行为的日常情节",
"合规、稳妥的日常情节",
),
(
re.compile(r"治愈|根治|药到病除|包治|抗癌|无副作用|百分之百(?:有效|治愈)|永久(?:有效|瘦)"),
@@ -584,6 +805,18 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
re.compile(r"(?:模仿|复刻|扮演|像).{0,16}(?:明星|艺人|名人|网红|演员)"),
"采用原创人物设定与表演",
),
(
re.compile(r"(?<!\d)18\s*(?:-|—|–|~|至|到)\s*22\s*岁\s*(?:软甜)?少女音"),
"二十二岁以上成年女性的清甜自然声线",
),
(
re.compile(r"(?<!\d)18\s*(?:-|—|–|~|至|到)\s*22\s*岁"),
"二十二岁以上",
),
(re.compile(r"少女"), "年轻成年女性"),
(re.compile(r"甜妹"), "甜美风成年女性"),
(re.compile(r"低角度轻微仰拍"), "略低于视线的正面拍摄"),
(re.compile(r"低角度鱼眼机位"), "正面鱼眼机位"),
)
@@ -774,31 +1007,75 @@ def default_reply_options(
]
if has_context:
text = str(assistant_text or "")
if re.search(r"颜色|色号|色彩|SKU|款式|几种", text, re.IGNORECASE):
# 创作描述常同时出现商品、人物、场景、颜色。快捷回复只看末尾真正交给用户
# 决定的部分,避免正文里的普通名词抢走最后一句的意图。
paragraphs = [part.strip() for part in re.split(r"\n+", text) if part.strip()]
source = paragraphs[-1] if paragraphs else text
sentences = [part.strip() for part in re.split(r"(?<=[。!?!?])", source) if part.strip()]
guidance = "".join(sentences[-2:])[-240:] if sentences else source[-240:]
asks_for_detail = bool(re.search(
r"(?:额外|另外|其他|重点).{0,12}(?:突出|强调).{0,12}(?:细节|重点|卖点)|"
r"(?:细节|重点|卖点).{0,12}(?:突出|强调)",
guidance,
re.IGNORECASE,
))
asks_for_color_order = bool(re.search(
r"配色.{0,12}(?:顺序|排序|调换|调整)|(?:调换|调整).{0,12}配色",
guidance,
re.IGNORECASE,
))
if asks_for_detail and asks_for_color_order:
return [
{"label": "补充突出细节", "text": "我想补充需要额外突出的细节"},
{"label": "调整配色顺序", "text": "我想调整配色的展示顺序"},
{"label": "按当前描述继续", "text": "没有其他调整,按当前描述继续"},
]
if re.search(r"颜色|色号|色彩|配色|SKU|款式|几种|展示顺序", guidance, re.IGNORECASE):
return [
{"label": "补充颜色和顺序", "text": "我来补充每个颜色和展示顺序"},
{"label": "上传各款实物图", "text": "我补充各颜色/款式的实物图"},
{"label": "先按当前主款做", "text": "先按当前主款做,其他颜色后面再补"},
]
if re.search(r"商品|产品|主推|哪款", text, re.IGNORECASE):
if re.search(
r"(?:哪款|哪个|什么).{0,8}(?:商品|产品)|"
r"(?:商品|产品).{0,12}(?:选择|选|换|更换|主推|想推|要推)|"
r"(?:选择|选|换|更换|主推|想推|要推).{0,12}(?:商品|产品)",
guidance,
re.IGNORECASE,
):
return [
{"label": "发商品列表", "text": "把商品列表发给我选"},
{"label": "我直接说商品名", "text": "我直接告诉你商品名"},
{"label": "你来推荐", "text": "你根据当前需求推荐一款"},
]
if re.search(r"人物|角色|模特|出镜", text, re.IGNORECASE):
if re.search(
r"(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|"
r"(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)",
guidance,
re.IGNORECASE,
):
return [
{"label": "上传人物图", "text": "我上传人物参考图"},
{"label": "由你设定角色", "text": "你先帮我设定一个合适的角色"},
{"label": "不需要人物", "text": "这条先不需要人物出镜"},
]
if re.search(r"场景|地点|背景|在哪", text, re.IGNORECASE):
if re.search(
r"(?:哪里|哪儿|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:场景|地点|背景)|"
r"(?:场景|地点|背景).{0,12}(?:哪里|哪儿|选择|选|换|更换|调整|修改|改)",
guidance,
re.IGNORECASE,
):
return [
{"label": "上传场景图", "text": "我上传场景参考图"},
{"label": "你来推荐场景", "text": "你按商品和预设推荐场景"},
{"label": "用干净日常场景", "text": "先用干净自然的日常场景"},
]
if re.search(r"卖点|功能|效果|优惠|价格", text, re.IGNORECASE):
if re.search(
r"(?:补充|选择|选|换|更换|调整|修改|改|突出|强调).{0,12}(?:卖点|功能|效果|优惠|价格)|"
r"(?:卖点|功能|效果|优惠|价格).{0,12}(?:补充|选择|选|换|更换|调整|修改|改|突出|强调)",
guidance,
re.IGNORECASE,
):
return [
{"label": "补充真实卖点", "text": "我来补充商品真实卖点"},
{"label": "从素材里判断", "text": "先根据我上传的素材判断可表达的卖点"},
@@ -965,6 +1242,10 @@ 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("pain_point_direction_ready", None)
memory.pop("pain_point_direction", None)
memory.pop("click_swap_ready", None)
memory.pop("click_swap_sequence", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
@@ -1181,6 +1462,15 @@ def confirm_param_options(is_video: bool) -> dict:
}
def _normalize_confirm_duration(value) -> tuple[str, int | str]:
"""确认卡只按实际秒数判断时长变化,兼容「8秒 / 8 秒」等历史格式。"""
raw = str(value or "").strip()
match = re.search(r"\d+(?:\.\d+)?", raw)
if match:
return "seconds", int(float(match.group()))
return "label", re.sub(r"\s+", "", raw).lower()
def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]:
"""确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。"""
current = dict(conversation.params or {})
@@ -1192,11 +1482,13 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
value = str(raw or "").strip()
if not value or current.get(key) == value:
continue
if key == "duration" and _normalize_confirm_duration(current.get(key)) == _normalize_confirm_duration(value):
continue
current[key] = value
changed = True
duration_changed = (
conversation.mode == CreationConversation.Mode.VIDEO
and str(current.get("duration") or "") != old_duration
and _normalize_confirm_duration(current.get("duration")) != _normalize_confirm_duration(old_duration)
and bool(str(current.get("duration") or ""))
and bool(old_duration)
)
@@ -1330,6 +1622,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"description": (
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
"四个字段都必须写具体非空文案,禁止空字符串。"
"策略从第一稿就使用健康、正向、明确成年的人物与情节表达,不要复述需要规避的原始措辞。"
"调完会停下来等用户确认或提出修改,不要同轮接着 write_plan。"
"仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。"
),
@@ -1357,6 +1650,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"用户确认方案后由平台展示 Prompt。"
"video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、"
"声音/灯光/场景/参考素材锁定、逐镜四行细节和一致性收束,不要只写大纲。"
"第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
"先有已确认的 write_strategy,再调它。"
),
"parameters": {
@@ -1390,6 +1684,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"时长任务、标题、风格、镜头语言、视觉美术、色彩材质、打光、剪辑、声音、场景、主体参考、逐镜脚本、一致性收束。"
"每一镜严格包含拍法/画面内容/主体或产品露出/声音四行;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。"
"已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字。"
"人物必须明确为成年人且构图得体;只写正向可拍内容,不列风险词或禁用词。"
),
},
},
@@ -1655,11 +1950,34 @@ def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str
return (
f"{prompt.strip()}\n\n"
f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}–{end} 秒的内容。"
"仅呈现这一时间段对应的情节与镜头,承接上一段的角色、服装、商品、场景和光线,"
"仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的同一位人物,"
"不得在本段重新设计、随机替换或改变其五官、发型、年龄、身形和服装。"
"承接上一段的动作、商品、场景和光线,"
"为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。"
)
def apply_person_identity_guard(prompt: str, references: list[dict]) -> str:
"""把解析后的人物参考编号写进最终出片 Prompt。
resolve_refs 会把人物排在最前,但仍按真实位置计算 @图N,避免混入
场景/商品后编号指错。
"""
indexes = [
index for index, item in enumerate(references or [], start=1)
if isinstance(item, dict) and item.get("type") in {"model", "character"}
]
if not indexes:
return prompt
labels = "、".join(f"参考图{index}" for index in indexes)
return (
f"{prompt.strip()}\n\n【人物一致性硬约束】{labels}定义本片的固定出镜人物。"
"整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、"
"肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔,"
"不得因镜头或光线变化而改变身份。"
)
def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | None = None) -> int:
"""显式时长优先;智能时长从方案/Prompt 推导,完全缺失才回退 15 秒。"""
raw = str(params.get("duration") or "")
@@ -1804,6 +2122,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
active_plot_twist_story_depth(context.conversation),
prompt,
)
prompt = apply_person_identity_guard(prompt, resolved.references)
duration = resolve_smart_video_duration(context.conversation, prompt=prompt)
# resolve_smart_video_duration 可能为了完整脚本切到支持长时长的模型,必须重新取参数。
params = context.conversation.params or {}
@@ -1818,6 +2137,9 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
"generate_audio": True,
"references": resolved.references,
}
if any(item.get("type") in {"model", "character"} for item in resolved.references):
# 长视频的多个任务共用同一个确定性 seed,减少两段各自随机采样导致的脸部/服装漂移。
submit["seed"] = int(str(context.conversation.id).replace("-", "")[:8], 16)
return submit, resolved.references
@@ -1901,10 +2223,19 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
tasks = []
try:
for segment in segments:
segment_prompt = (
segment_video_prompt(submit["prompt"], segment, total_duration)
if len(segments) > 1
else submit["prompt"]
)
segment_submit = {
**submit,
"duration": int(segment["duration"]),
"prompt": segment_video_prompt(prompt, segment, total_duration) if len(segments) > 1 else submit["prompt"],
# 长视频也必须从完整的最终 Prompt 分段。以前这里误用原始 prompt,
# 会丢掉预设约束、商品安全约束和人物锁定,是 60s 前后换人的直接原因。
# 分段之后再执行一次统一清洗:移除模型/用户写进正文的任何屏显指令,
# 并让每个独立视频任务的首尾都带最高优先级画面洁净约束。
"prompt": enforce_no_embedded_captions(segment_prompt),
"extra_payload": {
"omni_segment": {
"index": segment["index"],
@@ -2135,6 +2466,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"- 可以自己决定转场、灯光、普通镜头细节;商品功能、规格、价格、活动、功效和关键使用边界不能猜,缺失时一次只问一项。",
"- 用户上传的人物、商品、服装和场景优先作为参考;如确实需要额外生成角色、场景或道具,先说明用途和预计积分,等用户确认。",
"- 出片前检查:主卖点都有画面或台词证据、口播能在时长内说完、脚本中每位人物/商品/服装/场景都有对应素材、商品正常使用、全片一致、所有 SKU 都已安排。内容超出时长时建议删减、延长或拆分,而不是硬塞。",
"- 安全检查前置到创作第一稿:人物默认明确为成年人,服装与构图得体;策略、方案和 video_prompt 只写改写后的正向可拍内容,不复述风险情节,不罗列平台禁用词,也不写否定式免责声明。",
])
# 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径)
try:
@@ -2237,6 +2569,21 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"每张卡写清冲突、商品如何推进剧情、反转和情绪;不得只在文字中说‘我准备了三个方向’,"
"不得先 write_strategy / write_plan,也不要让用户输入编号。"
)
if is_pain_point_conversation(conversation):
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
selected_direction = str(memory.get("pain_point_direction") or "").strip()
if selected_direction:
lines.append(
f"用户已选择痛点方向:【{selected_direction}】。这个方向就是已确认的核心卖点;"
"直接围绕它写策略,不得再询问核心卖点。"
)
else:
lines.append(
"【强制下一步·痛点方向三选一】先根据商品事实与可见素材整理恰好 3 个明显不同、可被画面证明的痛点方向。"
"必须调用 ask_user:field key 固定为 pain_point_direction,type=single,options 恰好 3 项;"
"每个 label 直接写完整的‘具体困扰 + 商品正常使用后可见结果’,让用户点击即选中。"
"不得只在正文里列三条,不得要求用户回复编号,不得先 write_strategy,也不得另问核心卖点。"
)
workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else ""
if workflow_guidance:
lines.append(f"【当前预设的工作重点】{workflow_guidance}")
@@ -2516,6 +2863,64 @@ def _coerce_strategy_args(args: dict) -> dict:
}
_STRATEGY_TEXT_SECTION_ALIASES = {
"目标受众": "target",
"目标人群": "target",
"这条视频给谁看": "target",
"给谁看": "target",
"用户为什么相信": "trust",
"为什么相信": "trust",
"内容逻辑": "trust",
"可信依据": "trust",
"信任依据": "trust",
"希望用户相信什么": "belief",
"希望相信": "belief",
"核心卖点": "belief",
"核心主张": "belief",
"创作方向": "direction",
"视觉调性": "direction",
"视觉风格": "direction",
"表达方向": "direction",
}
def strategy_args_from_text(text: str) -> dict:
"""模型漏调 write_strategy 时,把带明确栏目名的策略正文救回结构化卡片。
只接受四个栏目都能识别的高置信文本,普通聊天不会被误转成策略卡。
"""
sections = {"target": [], "trust": [], "belief": [], "direction": []}
current = ""
for raw_line in str(text or "").splitlines():
line = re.sub(r"^\s*(?:[-*#>]+\s*)?", "", raw_line).replace("**", "").strip()
if not line:
continue
match = re.match(r"^([^::\n]{2,36})\s*[::]\s*(.*)$", line)
if match:
heading = re.sub(r"[((].*$", "", match.group(1)).strip().replace(" ", "")
key = _STRATEGY_TEXT_SECTION_ALIASES.get(heading)
if key:
current = key
content = match.group(2).strip()
if content:
sections[key].append(content)
continue
if heading in {"创作策略", "策略理解", "创作策略理解"}:
current = ""
continue
if not current:
continue
if re.match(r"^(?:你看|请确认|如果你|是否需要|可以再)", line):
continue
sections[current].append(line)
payload = {
key: "\n".join(parts).strip()
for key, parts in sections.items()
}
return payload if all(payload.values()) else {}
def _coerce_plan_card_args(args: dict) -> dict:
usp = _pick_str(args, "usp", "主打卖点", "卖点", "core_usp", "main_point")
points = _coerce_points(
@@ -2543,6 +2948,69 @@ def _coerce_plan_card_args(args: dict) -> dict:
}
def apply_click_swap_plan_card(
conversation: CreationConversation,
card: dict,
duration: int,
) -> dict:
"""把模型可能写偏的方案卡收束成可核对的点击换款时间轴。"""
if not is_click_swap_preset(conversation.preset):
return card
sequence = click_swap_sequence(conversation)
if not sequence:
return card
total = max(4, min(int(duration or 0), 60))
first_end = max(1, round(total * 0.2))
second_end = max(first_end + 1, round(total * 0.45))
third_end = max(second_end + 1, round(total * 0.75))
third_end = min(third_end, total - 1)
second_end = min(second_end, third_end - 1)
return {
**card,
"usp": f"手指逐次点击,商品按「{sequence}」在原位连续换款",
"points": [
"固定机位、背景、光线与商品中心位置",
"每次换款都由一次清楚的手指点击触发",
f"严格按「{sequence}」逐款展示,结尾给出全款式总览",
],
"timeline": [
{
"start": 0,
"end": first_end,
"stage": "首款定帧",
"desc": "固定机位建立首款,商品位置、尺寸、角度和背景作为后续唯一基准。",
},
{
"start": first_end,
"end": second_end,
"stage": "首次点击换款",
"desc": "手指清晰点击商品,接触瞬间在原位 match cut 为下一款。",
},
{
"start": second_end,
"end": third_end,
"stage": "按序连续换款",
"desc": f"按「{sequence}」继续一触一换;机位、构图、商品比例和光线完全不变。",
},
{
"start": third_end,
"end": total,
"stage": "全款式收束",
"desc": "保持同一构图完成全款式总览,不加入口播、剧情、换景或字幕。",
},
],
"matrix": {
"shots": 4,
"rows": [
{"point": "固定构图", "hits": [1, 2, 3, 4]},
{"point": "点击触发", "hits": [2, 3]},
{"point": "款式顺序", "hits": [1, 2, 3, 4]},
],
},
"voice_chars": [0, 0],
}
def iter_creation_agent_events(
*,
conversation: CreationConversation,
@@ -2633,6 +3101,27 @@ def iter_creation_agent_events(
yield {"type": "done"}
return
# 点击换款的款式清单和顺序是脚本事实,不能让模型自行猜色号或把预设改成普通展示片。
if click_swap_needs_sequence(conversation):
question = append_click_swap_sequence_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
# 需要真人/角色的视频必须先选定人物来源。这是平台闸门,
# 不交给模型自由发挥,否则它会在脚本里随机造人,到 60s 分段时必然漂移。
if video_needs_person_source(conversation, text):
question = append_person_source_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:
@@ -2748,6 +3237,26 @@ def iter_creation_agent_events(
"asset_types": ["product"],
}]
# 痛点解决预设若模型只写了三条列表却漏调 ask_user,平台直接把这三条
# 转成单选按钮;不允许用户再手抄一遍,也不进入重复的核心卖点闸门。
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if (
not calls
and fallback_fields is None
and allow_plan
and is_pain_point_conversation(conversation)
and not memory.get("selling_point_ready")
):
pain_options = pain_point_direction_options_from_text(said)
if pain_options:
fallback_fields = [{
"key": PAIN_POINT_DIRECTION_KEY,
"label": "选择这条视频要重点解决的痛点",
"type": "single",
"required": True,
"options": pain_options,
}]
# 方向卡是剧情反转预设的固定入口。模型偶尔只会说「我准备了三个方向」而忘了调工具,
# 此处直接补上可点击卡,不能让用户面对一段空话再自己追问。
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
@@ -2770,6 +3279,32 @@ def iter_creation_agent_events(
turn_has_gate = True
break
# 有些模型会把完整策略按「核心卖点/目标受众/内容逻辑/视觉调性」写成散文,
# 却漏掉 write_strategy 工具调用。高置信识别后直接走同一条结构化策略闸门,
# 避免前端退化成一整块普通聊天气泡,也避免缺失「按这个继续」。
current_stage = get_video_gate_stage(conversation)
may_write_strategy = (
current_stage == "clarify"
or (current_stage == "strategy" and not bool(memory.get("strategy_confirmed")))
)
prose_strategy = (
strategy_args_from_text(said)
if not calls and context.is_video and may_write_strategy
else {}
)
if prose_strategy:
result, _stop = _dispatch_tool(context, "write_strategy", prose_strategy)
for event in result.get("_events", []):
yield event
if event.get("type") == "message":
msg = event.get("message") or {}
if msg.get("kind") in (
CreationMessage.Kind.ELICIT,
CreationMessage.Kind.CONFIRM,
):
turn_has_gate = True
break
# ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
# 另存一条,否则界面会连续出现两遍几乎相同的问题。
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
@@ -3024,6 +3559,8 @@ def _guided_elicit_text(field: dict) -> str:
return label
if str(field.get("key") or "") in SESSION_PARAM_KEYS:
return label
if field.get("type") == "single" and field.get("options"):
return f"{label} 直接点击一个选项,也可以输入自己的想法。"
return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。"
@@ -3137,7 +3674,34 @@ def _dispatch_tool(
return {"payload": _run_search_library(context, args)}, False
if name == "write_strategy":
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")
return {
"payload": {"asked": True, "field": "sku_sequence"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
if context.is_video and video_needs_person_source(
context.conversation,
json.dumps(args if isinstance(args, dict) else {}, ensure_ascii=False),
):
gate = append_person_source_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "person_source"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {}
if context.is_video and is_pain_point_conversation(context.conversation) and not memory.get("selling_point_ready"):
return {
"payload": {
"error": (
"痛点解决演示必须先调用 ask_user 展示痛点方向三选一:"
"key=pain_point_direction、type=single、恰好 3 个 options。"
"用户点击后该方向会直接成为核心卖点,不要另开卖点确认卡。"
)
}
}, False
if context.is_video and not memory.get("selling_point_ready"):
gate = append_selling_point_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
@@ -3180,7 +3744,26 @@ def _dispatch_tool(
video_prompt = str(args.get("video_prompt") or "").strip()
if not video_prompt:
return {"payload": {"error": "video_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")
return {
"payload": {"asked": True, "field": "sku_sequence"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
if context.is_video and video_needs_person_source(context.conversation, video_prompt):
gate = append_person_source_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "person_source"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt)
if is_click_swap_preset(context.conversation.preset):
video_prompt = (
f"{video_prompt}\n\n【商家确认的换款顺序】{click_swap_sequence(context.conversation)}\n"
"只允许按这个顺序逐款切换;不得跳序、漏款或自行增加颜色和款式。"
)
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
video_prompt = apply_product_reality_guard(video_prompt)
video_prompt = apply_video_platform_safety_guard(video_prompt)
@@ -3218,6 +3801,7 @@ def _dispatch_tool(
prompt=video_prompt,
timeline=card["timeline"],
)
card = apply_click_swap_plan_card(context.conversation, card, duration)
lo = max(20, round(duration * 3.4))
hi = max(lo + 1, round(duration * 4))
plan_payload = {
@@ -3225,7 +3809,11 @@ def _dispatch_tool(
"points": card["points"],
"timeline": card["timeline"],
"matrix": card["matrix"],
"voice_chars": _coerce_voice_chars(card["voice_chars"], [lo, hi]),
"voice_chars": (
[0, 0]
if is_click_swap_preset(context.conversation.preset)
else _coerce_voice_chars(card["voice_chars"], [lo, hi])
),
"ref_count": len(context.conversation.pinned_refs or []),
}
events = []
@@ -3251,6 +3839,13 @@ def _dispatch_tool(
video_prompt = get_pending_video_prompt(context.conversation)
if not video_prompt:
return {"payload": {"error": "video_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")
return {
"payload": {"asked": True, "field": "sku_sequence"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt)
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
video_prompt = apply_product_reality_guard(video_prompt)