优化视频创作

This commit is contained in:
Azmat@qq.com
2026-09-15 15:17:05 +08:00
parent 99a126d19b
commit 7098245a4a
21 changed files with 262 additions and 220 deletions
+48 -22
View File
@@ -27,7 +27,7 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
from .creation import append_message, pin_refs
from .creation_presets import apply_image_preset_prompt, preset_guidance
from .creation_presets import apply_image_preset_prompt, preset_guidance, preset_workflow_guidance
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
@@ -41,12 +41,13 @@ MAX_TOOL_ROUNDS = 8
MAX_BILLED_GENERATIONS = 1
# 视频闸门阶段(落在 conversation.memory.stage;resume 靠它)
# clarify → strategy → plan → prompt → confirm → done
# prompt 仅兼容历史会话。新视频链路在方案确认后直接转入出片确认,Prompt 始终由平台在后台维护。
# clarify → strategy → plan → confirm → done
VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done")
_STEP_CONFIRM_LABELS = {
"strategy": "创作策略已写好。确认后继续写方案;要改就点「我想改」或直接说改哪里。",
"plan": "视频方案已写好。确认后继续出 Prompt;要改就点「我想改」或直接说改哪里。",
"prompt": "出片 Prompt 已写好。确认后选择参数并生成;要改就点「我想改」或直接说改哪里。",
"plan": "视频方案已写好。确认后我会整理出片细节,并带你确认生成参数;要改就点「我想改」或直接说改哪里。",
"prompt": "出片指令已整理好。确认后核对参数并生成;要改就点「我想改」或直接说改哪里。",
}
@@ -1552,7 +1553,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。",
"- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。",
"- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了就写策略。"
" 视频每写完一步(策略/方案/Prompt)平台会出确认卡,由用户点「按这个继续」或提出修改;不要口头问流程。",
" 视频每写完策略方案,平台会出确认卡;方案确认后平台会在后台整理出片指令,再让用户核对生成参数。不要口头问流程。",
"- 用户打招呼或闲聊(hi / 你好 / 在吗 / 你在干什么 / 嗯 / 好的 / ok):"
" **禁止**调用 write_strategy、write_plan、generate_image,不要「整理方案」或直接开写脚本。",
"- 会话里**还没有**商品/方向时:自然地告诉用户可以直接丢一句想法,别硬推销,也别用客服式结束语。",
@@ -1582,6 +1583,15 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
]
if context.is_video:
lines.extend(["", _OMNI_VIDEO_PROMPT_RULES.strip()])
lines.extend([
"",
"【视频创作工作原则】",
"- 先从文字和已锁定素材整理商品事实、人物/服装、场景、参考视频或音频、现成脚本,以及时长、比例和语言;已经给出的信息不重复问。",
"- 用户已给脚本或分镜时,以它为基础补足,不强制从头重写。只改用户点名的镜头、人物、商品、台词或参数,其他内容保持。",
"- 可以自己决定转场、灯光、普通镜头细节;商品功能、规格、价格、活动、功效和关键使用边界不能猜,缺失时一次只问一项。",
"- 用户上传的人物、商品、服装和场景优先作为参考;如确实需要额外生成角色、场景或道具,先说明用途和预计积分,等用户确认。",
"- 出片前检查:主卖点都有画面或台词证据、口播能在时长内说完、脚本中每位人物/商品/服装/场景都有对应素材、商品正常使用、全片一致、所有 SKU 都已安排。内容超出时长时建议删减、延长或拆分,而不是硬塞。",
])
# 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径)
try:
dur = video_duration(params)
@@ -1591,14 +1601,13 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
hi = max(lo, min(85, int(dur * 5.7)))
lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}{hi} 字;write_plan 的 voice_chars 填这个区间。")
lines.append(
"- 视频 5 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → "
"③用户确认后 write_plan 停等确认 → ④用户确认后 write_prompt(或平台代出 Prompt)停等确认 → "
"⑤用户确认后才出现积分确认卡出片。"
"- 视频 4 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → "
"③用户确认后 write_plan 停等确认 → ④方案确认后平台在后台整理完整出片指令,并直接出现积分确认卡。"
)
lines.append(
"- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan / write_prompt;"
"- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;"
"闲聊与打招呼绝对不要。用户对某一步提出修改时,只重写那一步,不要跳到后面。"
"video_prompt 按上面的秒级分镜规范写满,不要只给大纲,也禁止只回「好的,有需要再说」。"
"write_plan 里的 video_prompt 按上面的秒级分镜规范写满,供平台后台出片使用;不要只给大纲也禁止只回「好的,有需要再说」。"
)
stage = get_video_gate_stage(conversation)
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
@@ -1606,13 +1615,13 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
stage_hint = {
"clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了只调 write_strategy。",
"strategy": (
"当前阶段=策略已确认,请调用 write_plan 写方案;不要再写策略,不要 write_prompt"
"当前阶段=策略已确认,请调用 write_plan 写方案;不要再写策略。"
if strategy_confirmed else
"当前阶段=等策略确认:不要再 write_plan/write_prompt;用户确认后才会进入方案。若用户在改策略,只重调 write_strategy。"
"当前阶段=等策略确认:不要再写方案或出片;用户确认后才会进入方案。若用户在改策略,只重调 write_strategy。"
),
"plan": "当前阶段=等方案确认:不要 write_prompt 或出片。若用户在改方案,只重调 write_plan。",
"prompt": "当前阶段=等 Prompt 确认:不要出积分确认/出片。若用户在改 Prompt,只重调 write_prompt",
"confirm": "当前阶段=等出片确认:不要再写策略/方案/Prompt;用户会在确认卡上点开始生成。",
"plan": "当前阶段=等方案确认:不要出片。若用户在改方案,只重调 write_plan。",
"prompt": "当前阶段=兼容历史会话的出片指令确认:不要出片,按用户反馈重写内部出片指令",
"confirm": "当前阶段=等出片确认:不要再写策略/方案;用户会在确认卡上点开始生成。",
"done": "当前阶段=已出片:等用户新的修改或新需求再行动。用户说重新来/重来/从头开始时,当作新一轮创作,从澄清或 write_strategy 重开,不要再弹旧模特/商品追问。",
}.get(stage, "")
if stage_hint:
@@ -1651,6 +1660,9 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
if guidance:
lines.append(guidance)
lines.append("用户选了这个预设,就按它的拍法来;要偏离得先问过用户。")
workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else ""
if workflow_guidance:
lines.append(f"【当前预设的工作重点】{workflow_guidance}")
resolved = resolve_refs(context.team, conversation.pinned_refs or [])
if resolved.facts:
@@ -2344,7 +2356,7 @@ _TOOL_LABELS = {
"generate_image": "生成图片",
"write_strategy": "梳理创作策略",
"write_plan": "编排视频方案",
"write_prompt": "编写出片 Prompt",
"write_prompt": "整理出片指令",
}
@@ -2403,11 +2415,21 @@ def _elicit_payload_for_fields(
else:
gate_label = _GATE_LABELS.get(primary_type, _GATE_LABELS["asset"])
asset_label = {
"product": "商品",
"character": "角色",
"model": "模特",
"scene": "场景",
}.get(primary_type, "素材")
gate_field = {
"key": "_asset_gate",
"label": gate_label,
"type": "text",
"required": False,
"options": [
{"value": "send", "label": f"{asset_label}列表"},
{"value": "auto", "label": "你来推荐"},
],
}
return {
# 这是 Agent 的一句自然追问,不是让用户点选流程的卡片。
@@ -2526,7 +2548,7 @@ def _dispatch_tool(
kind=CreationMessage.Kind.PLAN, payload=plan_payload,
)
events.append({"type": "message", "message": _message_payload(plan)})
# video_prompt 先缓存:用户确认方案后由平台展示 Prompt 卡,本步不出 Prompt/积分卡
# video_prompt 只存后台。用户确认方案后直接进入积分确认,不展示内部 Prompt
set_video_gate_stage(
context.conversation, "plan", pending_video_prompt=video_prompt
)
@@ -2545,12 +2567,16 @@ def _dispatch_tool(
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
video_prompt = apply_product_reality_guard(video_prompt)
emitted = emit_prompt_gate(context.conversation, video_prompt)
if not emitted:
return {"payload": {"error": "无法写出 Prompt 卡"}}, False
events = [{"type": "message", "message": _message_payload(m)} for m in emitted]
set_video_gate_stage(context.conversation, "prompt", pending_video_prompt=video_prompt)
confirm = emit_final_confirm_gate(context.conversation, context=context)
if confirm is None:
return {"payload": {"error": "无法准备出片确认"}}, False
events = [
{"type": "message", "message": _message_payload(confirm)},
{"type": "credits", "estimated": int((confirm.payload or {}).get("estimated_credits") or 0)},
]
return {
"payload": {"awaiting_step": "prompt", "written": True},
"payload": {"awaiting_confirmation": True, "prepared": True},
"_events": events,
}, True
+48 -3
View File
@@ -1,6 +1,6 @@
"""全能创作 · 预设(契约 §6)。
首页 8+6 预设卡不只是个名字 每个预设代表一套**明确的拍法**
首页预设卡不只是个名字 每个预设代表一套**明确的拍法**
只把达人口播种草四个字塞进提示词,模型只能靠猜;这里给它可执行的约束
新增预设 = 在下面加一条,前端 `omni-create.tsx` PRESET 列表加一张卡两边的
@@ -9,6 +9,10 @@ key 必须是同一个中文名(会话建的时候原样存进 CreationConversat
from __future__ import annotations
VIDEO_PRESETS: dict[str, str] = {
"痛点解决演示": (
"真实问题解决型短片。按「具体痛点 → 商品自然登场 → 正常使用步骤 → 可见结果 → 轻行动引导」推进。"
"痛点必须来自用户提供的商品用途;使用方法、效果与结果只写能被素材或商品事实证明的内容,禁止夸大功效或虚构测试。"
),
"真实使用演示": (
"可信实拍型商品短片。先按商品真实用途、可见结构和已有卖点安排演示,再谈氛围和转化;"
"每一个卖点都必须由看得见的使用步骤、材质细节或结果承接,不能凭空加性能。"
@@ -40,9 +44,14 @@ VIDEO_PRESETS: dict[str, str] = {
"鱼眼/广角近距离透视,连续换装节奏。**人物面部和身形必须全程一致**,"
"只有服装在变。每次换装用一个明确动作触发。"
),
"多色商品换款": (
"先识别并整理不同颜色、款式或 SKU,确保每款的比例、位置、外观与顺序清楚。"
"统一构图和机位,用点击/触碰或一个明确动作触发换款;背景、光线、机位全程稳定,最后用全家福镜头覆盖全部款式。"
),
# 旧会话仍会保存「点触换款」,保留同一拍法约束以支持历史继续创作。
"点触换款": (
"统一构图和机位,用点击/触碰动作触发商品款式切换,快速展示多个 SKU"
"背景、光线、机位全程不变 —— 变化只发生在商品本身"
"先识别并整理不同颜色、款式或 SKU,确保每款的比例、位置、外观与顺序清楚"
"统一构图和机位,用点击/触碰或一个明确动作触发换款;背景、光线、机位全程稳定,最后用全家福镜头覆盖全部款式"
),
"探店漫游": (
"以空间动线串联:入口 → 环境 → 关键细节 → 服务/主推项目。"
@@ -52,6 +61,37 @@ VIDEO_PRESETS: dict[str, str] = {
"强调光影、材质和镜头节奏,建立品牌识别。慢节奏、精致构图、克制的色彩。"
"少即是多,不要堆信息。"
),
"前后对比实测": (
"围绕同一对象和同一可比较条件,依次拍清使用前状态、正常使用过程、使用后状态与可见对比。"
"优先使用用户提供的前/中/后素材;没有事实依据时不承诺功效、不制造夸张差异。"
),
"AI 宠物拟人": (
"先确定宠物的外观、性格和可执行动作,再设计一段轻巧剧情。宠物与商品互动要符合商品真实结构和正常操作方式;"
"趣味来自角色反应和剧情,不把商品改造成不真实的玩具或凭空增加能力。"
),
"人物与音色替换": (
"先理解原视频的镜头、人物、动作、台词、画外音、字幕、场景和节奏。只替换用户指定的人物、声音或两者;"
"其他构图、运镜、动作、商品、场景和剪辑节奏尽量保持原样。用户未提供原视频或替换目标时,先问这一个必要信息。"
),
}
# 预设除了决定最终 Prompt,也决定 Agent 在素材整理、追问与生成前检查时的工作重点。
# 这段进入系统提示词,避免把预设退化成一句风格修饰词。
VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
"痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。",
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
"剧情反转带货": "优先确认人物关系或冲突;缺剧情时给 2–3 个可选方向,商品必须成为解决问题或反转的关键。",
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
"达人口播种草": "优先确认达人/人物、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
"鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装随动作切换。",
"多色商品换款": "优先识别每个颜色或款式及其顺序;生成前核对所有 SKU 都已进入时间轴,比例、位置和机位保持一致。",
"点触换款": "优先识别每个颜色或款式及其顺序;生成前核对所有 SKU 都已进入时间轴,比例、位置和机位保持一致。",
"探店漫游": "优先确认门店动线与主推项目;用连续移动串联入口、环境、细节和服务,不做碎片化硬切。",
"品牌质感大片": "优先确认品牌气质、材质和商品主卖点;由 Agent 决定光线和镜头,不把专业选择反复抛给用户。",
"前后对比实测": "优先确认同一对象的前、中、后素材或可比较条件;生成前核对对比不夸大且没有缺失关键阶段。",
"AI 宠物拟人": "优先确认宠物角色参考、性格和商品互动方式;生成前核对宠物、商品结构和使用动作前后一致。",
"人物与音色替换": "先确认原视频、替换对象和替换范围;未经要求不得改变原视频的构图、动作、商品、场景或节奏。",
}
IMAGE_PRESETS: dict[str, str] = {
@@ -83,6 +123,11 @@ def preset_guidance(name: str) -> str:
return ALL_PRESETS.get((name or "").strip(), "")
def preset_workflow_guidance(name: str) -> str:
"""预设名 → Agent 的素材、追问和检查重点。"""
return VIDEO_PRESET_WORKFLOWS.get((name or "").strip(), "")
def apply_image_preset_prompt(name: str, prompt: str) -> str:
"""将已选图片预设的风格字段确定性并入实际出图指令,不依赖 Agent 自行复述。"""
base = str(prompt or "").strip()
+23 -10
View File
@@ -217,6 +217,10 @@ class AskUserTests(CreationAgentBaseTests):
self.assertEqual(payload["interaction"], "chat")
self.assertEqual(payload["phase"], "gate")
self.assertEqual(payload["fields"][0]["key"], "_asset_gate")
self.assertEqual(
payload["fields"][0]["options"],
[{"value": "send", "label": "发商品列表"}, {"value": "auto", "label": "你来推荐"}],
)
self.assertEqual(payload["pending_fields"][0]["type"], "asset")
self.assertEqual(payload["pending_fields"][0]["asset_types"], ["product"])
self.assertIn("商品库列表", elicit[0]["message"]["text"])
@@ -903,7 +907,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
# 方案闸门停下,不能同轮出 Prompt/积分卡
self.assertEqual(len(fake.calls), 1)
def test_write_prompt_emits_prompt_and_step_confirm(self):
def test_write_prompt_keeps_internal_prompt_and_emits_confirm(self):
fake = FakeProvider([
_tool_chunks("write_prompt", {"video_prompt": "0-3秒 近景手持商品…"}),
_text_chunks("不该跑到这一轮"),
@@ -912,13 +916,10 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
events = _events(stream_creation_agent(conversation=self.conversation, user=self.user,
text="做条视频", model_config=self.model))
kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"]
self.assertIn("prompt_file", kinds)
self.assertNotIn("confirm", kinds)
elicits = [
e["message"] for e in events
if e.get("type") == "message" and e["message"]["kind"] == "elicit"
]
self.assertEqual(elicits[-1]["payload"].get("step"), "prompt")
self.assertNotIn("prompt_file", kinds)
self.assertIn("confirm", kinds)
self.conversation.refresh_from_db()
self.assertEqual((self.conversation.memory or {}).get("stage"), "confirm")
self.assertEqual(len(fake.calls), 1)
def test_strategy_and_plan_same_round_stops_after_strategy(self):
@@ -1291,6 +1292,18 @@ class PresetGuidanceTests(CreationAgentBaseTests):
self.assertIn("真实使用演示", system)
self.assertIn("禁止无依据拍成破损、漏液、渗水", system)
def test_video_preset_adds_workflow_checks_not_only_a_final_prompt_style(self):
conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", preset="多色商品换款", params={},
)
context = AgentContext(conversation=conversation, user=self.user, model_config=self.model)
system = build_system_prompt(context)
self.assertIn("当前预设的工作重点", system)
self.assertIn("所有 SKU 都已进入时间轴", system)
self.assertIn("比例、位置和机位保持一致", system)
def test_product_reality_guard_is_added_to_final_prompt(self):
from .creation_agent import apply_product_reality_guard
@@ -1319,10 +1332,10 @@ class PresetGuidanceTests(CreationAgentBaseTests):
self.assertIn("避免高饱和、现代摄影感", prompt)
def test_every_frontend_preset_has_guidance(self):
"""前端 9 个视频 + 12 个图片预设都要有拍法,漏一个就等于那张卡是摆设。"""
"""视频和图片预设都要有拍法,漏一个就等于那张卡是摆设。"""
from .creation_presets import IMAGE_PRESETS, VIDEO_PRESETS
self.assertEqual(len(VIDEO_PRESETS), 9)
self.assertEqual(len(VIDEO_PRESETS), 14)
self.assertEqual(len(IMAGE_PRESETS), 12)
self.assertTrue(all(text.strip() for text in {**VIDEO_PRESETS, **IMAGE_PRESETS}.values()))
+15 -12
View File
@@ -40,7 +40,6 @@ from .creation_agent import (
apply_restart_intent,
apply_session_params,
emit_final_confirm_gate,
emit_prompt_gate,
is_greeting,
is_restart_intent,
restore_gated_step_after_cancel,
@@ -174,11 +173,11 @@ _STEP_CONTINUE_INSTRUCTIONS = {
"不要再写策略,不要调用 write_prompt,不要出片。"
),
"plan": (
"用户已确认视频方案。若尚未写出 Prompt,调用 write_prompt 展示出片 Prompt;"
"不要重写策略/方案,不要出积分确认卡或直接出片。"
"用户已确认视频方案。补齐完整 video_prompt 后直接调用 write_prompt 整理后台出片指令并给出积分确认卡;"
"不要重写策略/方案,也不要直接出片。"
),
"prompt": (
"用户已确认出片 Prompt。不要再写策略/方案/Prompt;"
"用户已确认后台出片指令。不要再写策略/方案;"
"平台会出积分确认卡,等用户点开始生成。"
),
}
@@ -193,8 +192,8 @@ _STEP_REVISE_INSTRUCTIONS = {
"(含完整 video_prompt 存档);写完即停,不要同轮 write_prompt 或出片。"
),
"prompt": (
"用户要求修改出片 Prompt。根据反馈只重新调用 write_prompt 一版修订 Prompt;"
"写完即停,不要出积分确认卡直接出片。"
"用户要求修改出片细节。根据反馈只重新调用 write_prompt 整理一版修订指令;"
"完成后给出积分确认卡,不要直接出片。"
),
}
@@ -249,17 +248,21 @@ def _handle_step_confirm_answer(
return None, True, _STEP_CONTINUE_INSTRUCTIONS["strategy"]
if step == "plan":
emitted = emit_prompt_gate(conversation)
if not emitted:
# 缓存丢:让 agent 补 write_prompt
confirm = emit_final_confirm_gate(conversation)
if confirm is None:
# 兼容旧会话或缓存丢:让 agent 补齐后台出片指令,再直接给出积分确认。
return None, True, _STEP_CONTINUE_INSTRUCTIONS["plan"]
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
credits = int((confirm.payload or {}).get("estimated_credits") or 0)
body = {
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(m).data for m in emitted],
}, status=200), False, ""
"messages": [CreationMessageSerializer(confirm).data],
}
if credits:
body["estimated_credits"] = credits
return JsonResponse(body, status=200), False, ""
if step == "prompt":
confirm = emit_final_confirm_gate(conversation)