diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 6d166c4..44694aa 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -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 diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index 1a689bc..21547ef 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -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() diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index c08bc65..6b62509 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -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())) diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 1dff056..e19ccdb 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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) diff --git a/core/frontend/public/assets/video-presets/ai-pet-personification.jpg b/core/frontend/public/assets/video-presets/ai-pet-personification.jpg new file mode 100644 index 0000000..0e7aaed Binary files /dev/null and b/core/frontend/public/assets/video-presets/ai-pet-personification.jpg differ diff --git a/core/frontend/public/assets/video-presets/ai-pet-personification.mp4 b/core/frontend/public/assets/video-presets/ai-pet-personification.mp4 new file mode 100644 index 0000000..a49b44b Binary files /dev/null and b/core/frontend/public/assets/video-presets/ai-pet-personification.mp4 differ diff --git a/core/frontend/public/assets/video-presets/creator-recommendation.jpg b/core/frontend/public/assets/video-presets/creator-recommendation.jpg new file mode 100644 index 0000000..c49e0c0 Binary files /dev/null and b/core/frontend/public/assets/video-presets/creator-recommendation.jpg differ diff --git a/core/frontend/public/assets/video-presets/creator-recommendation.mp4 b/core/frontend/public/assets/video-presets/creator-recommendation.mp4 new file mode 100644 index 0000000..e9fbe1c Binary files /dev/null and b/core/frontend/public/assets/video-presets/creator-recommendation.mp4 differ diff --git a/core/frontend/public/assets/video-presets/multi-sku-switch.jpg b/core/frontend/public/assets/video-presets/multi-sku-switch.jpg new file mode 100644 index 0000000..42bc7c8 Binary files /dev/null and b/core/frontend/public/assets/video-presets/multi-sku-switch.jpg differ diff --git a/core/frontend/public/assets/video-presets/multi-sku-switch.mp4 b/core/frontend/public/assets/video-presets/multi-sku-switch.mp4 new file mode 100644 index 0000000..c642cb9 Binary files /dev/null and b/core/frontend/public/assets/video-presets/multi-sku-switch.mp4 differ diff --git a/core/frontend/public/assets/video-presets/pain-point-demo.jpg b/core/frontend/public/assets/video-presets/pain-point-demo.jpg new file mode 100644 index 0000000..ff937b4 Binary files /dev/null and b/core/frontend/public/assets/video-presets/pain-point-demo.jpg differ diff --git a/core/frontend/public/assets/video-presets/pain-point-demo.mp4 b/core/frontend/public/assets/video-presets/pain-point-demo.mp4 new file mode 100644 index 0000000..9afc028 Binary files /dev/null and b/core/frontend/public/assets/video-presets/pain-point-demo.mp4 differ diff --git a/core/frontend/public/assets/video-presets/plot-twist-commerce.jpg b/core/frontend/public/assets/video-presets/plot-twist-commerce.jpg new file mode 100644 index 0000000..e77517d Binary files /dev/null and b/core/frontend/public/assets/video-presets/plot-twist-commerce.jpg differ diff --git a/core/frontend/public/assets/video-presets/plot-twist-commerce.mp4 b/core/frontend/public/assets/video-presets/plot-twist-commerce.mp4 new file mode 100644 index 0000000..201464a Binary files /dev/null and b/core/frontend/public/assets/video-presets/plot-twist-commerce.mp4 differ diff --git a/core/frontend/public/assets/video-presets/rhythm-outfit-change.jpg b/core/frontend/public/assets/video-presets/rhythm-outfit-change.jpg new file mode 100644 index 0000000..c554367 Binary files /dev/null and b/core/frontend/public/assets/video-presets/rhythm-outfit-change.jpg differ diff --git a/core/frontend/public/assets/video-presets/rhythm-outfit-change.mp4 b/core/frontend/public/assets/video-presets/rhythm-outfit-change.mp4 new file mode 100644 index 0000000..3a1f453 Binary files /dev/null and b/core/frontend/public/assets/video-presets/rhythm-outfit-change.mp4 differ diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index 681627b..e85fd9e 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -274,14 +274,14 @@ aside.sidebar { .brand { display: flex; align-items: center; justify-content: center; min-width: 0; color: var(--accent-black); } .brand-clip { display: block; - width: 112px; + width: 132px; height: 90px; overflow: hidden; transition: width var(--t-base), height var(--t-base); } .brand-logo { display: block; - width: 112px; + width: 132px; height: 90px; max-width: none; margin: 0; diff --git a/core/frontend/src/omni-create-page.css b/core/frontend/src/omni-create-page.css index e6b0e65..ae4ce6e 100644 --- a/core/frontend/src/omni-create-page.css +++ b/core/frontend/src/omni-create-page.css @@ -790,47 +790,9 @@ transform: translateY(7px); } -.omni-case-head { - display: flex; - align-items: flex-end; - justify-content: flex-start; - gap: 20px; - margin-bottom: 15px; -} - -.omni-case-head-label { - flex: 0 0 auto; - color: #252b35; - font-size: 17px; - font-weight: 750; - line-height: 30px; -} - -.omni-case-filters { - display: flex; - align-items: center; - gap: 4px; -} - -.omni-case-filters button { - height: 30px; - padding: 0 12px; - border-radius: 8px; - color: #707681; - background: transparent; - font-size: 13px; - font-weight: 600; - cursor: pointer; -} - -.omni-case-filters button.active { - color: #fff; - background: var(--black); -} - .omni-case-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } @@ -840,24 +802,24 @@ gap: 14px; } -.omni-image-preset-intro { +.omni-preset-intro { margin: 0 0 14px; text-align: center; } -.omni-image-preset-intro strong, -.omni-image-preset-intro span { +.omni-preset-intro strong, +.omni-preset-intro span { display: block; } -.omni-image-preset-intro strong { +.omni-preset-intro strong { color: var(--accent-black); font-size: 16px; font-weight: 500; line-height: 1.4; } -.omni-image-preset-intro span { +.omni-preset-intro span { margin-top: 4px; color: var(--black-alpha-56); font-size: 13px; @@ -899,6 +861,12 @@ background: #edf0f4; } +/* 视频样片本身是竖版,按竖构图展示,避免横向裁切掉人物和商品。 */ +.omni-case-card[data-mode="video"] .omni-case-visual { + height: auto; + aspect-ratio: 9 / 15; +} + .omni-case-visual img, .omni-case-visual video { width: 100%; diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 6794121..13be063 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -2250,23 +2250,25 @@ .omni-gate-actions { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 6px; } .omni-gate-actions button { - padding: 8px 14px; - border: 1px solid rgba(34, 42, 54, .12); - border-radius: 999px; - background: #fff; - color: #575d66; - font-size: 13px; + min-height: 30px; + padding: 0 10px; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + background: var(--surface); + color: var(--accent-black); + font-size: 12px; + font-weight: 500; cursor: pointer; } .omni-gate-actions button.primary { - border-color: var(--klein); - background: var(--klein); - color: #fff; + border-color: var(--heat-20); + background: var(--heat-12); + color: var(--heat); } .omni-gate-actions button:disabled { diff --git a/core/frontend/src/routes/omni-create.tsx b/core/frontend/src/routes/omni-create.tsx index 9ef4c66..b7a677b 100644 --- a/core/frontend/src/routes/omni-create.tsx +++ b/core/frontend/src/routes/omni-create.tsx @@ -35,18 +35,16 @@ type PresetItem = { desc?: string; starter?: string; cover: string; + previewVideo?: string; }; const VIDEO_PRESETS: PresetItem[] = [ - { name: "真实使用演示", category: "commerce", mode: "video", title: "真实使用演示", desc: "从商品真实用途出发,拍清使用过程与卖点证据,避免不合理的夸张演示。", starter: "基于商品的真实用途创作一条可信的使用演示:拍清使用步骤、产品状态和核心卖点,不夸张、不出现不合理的使用场景。", cover: "/assets/prototype/video-oneclick-film-v3.png" }, - { name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/prototype/photo-1529139574466-a303027c1d8b.jpg" }, - { name: "商品拟人广告", category: "commerce", mode: "video", title: "商品拟人广告", desc: "让商品成为故事主角,通过个性、动作和情绪完成趣味表达。", starter: "把商品塑造成有性格的拟人角色,创作一条轻快、有趣的短广告。", cover: "/assets/prototype/photo-1608248543803-ba4f8c70ae0b.jpg" }, - { name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/prototype/photo-1494790108377-be9c29b29330.jpg" }, - { name: "商品图一键成片", category: "commerce", mode: "video", title: "商品图一键成片", desc: "从商品参考图建立镜头语言,自动补齐场景、动作与转场。", starter: "根据我上传的商品图片生成一条节奏清晰的电商短视频。", cover: "/assets/prototype/photo-1556228578-8c89e6adf883.jpg" }, - { name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/prototype/photo-1483985988355-763728e1935b.jpg" }, - { name: "点触换款", category: "visual", mode: "video", title: "点触换款", desc: "用统一构图和动作触发商品变化,快速展示多个款式或 SKU。", starter: "创作一条通过点击动作连续切换不同商品款式的短视频。", cover: "/assets/prototype/unbranded-running-shoe-remix.png" }, - { name: "探店漫游", category: "story", mode: "video", title: "探店漫游", desc: "以空间动线串联门店环境、服务细节与主推项目。", starter: "根据门店素材创作一条有路线感和空间氛围的探店漫游视频。", cover: "/assets/prototype/photo-1497366754035-f200968a6e72.jpg" }, - { name: "品牌质感大片", category: "visual", mode: "video", title: "品牌质感大片", desc: "通过统一的光影、材质与镜头节奏建立更强的品牌识别。", starter: "创作一条强调光影、材质和品牌气质的高级感商品短片。", cover: "/assets/prototype/video-free-cinematic.png" }, + { name: "痛点解决演示", category: "commerce", mode: "video", title: "痛点解决演示", desc: "从真实痛点到正常使用,再用看得见的过程证明商品价值。", starter: "基于商品的真实用途创作一条痛点解决演示:先让人看见困扰,再拍清使用步骤和真实结果,不夸张、不出现不合理的测试。", cover: "/assets/video-presets/pain-point-demo.jpg", previewVideo: "/assets/video-presets/pain-point-demo.mp4" }, + { name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" }, + { name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/video-presets/creator-recommendation.jpg", previewVideo: "/assets/video-presets/creator-recommendation.mp4" }, + { name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/video-presets/rhythm-outfit-change.jpg", previewVideo: "/assets/video-presets/rhythm-outfit-change.mp4" }, + { name: "多色商品换款", category: "visual", mode: "video", title: "多色商品换款", desc: "统一商品比例与机位,用动作连续展示不同颜色和款式。", starter: "创作一条多色商品换款视频:统一构图展示不同颜色或款式,用明确动作触发切换,最后给出全款总览。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" }, + { name: "AI 宠物拟人", category: "story", mode: "video", title: "AI 宠物拟人", desc: "让宠物角色参与有趣小剧情,同时保留商品真实结构与用途。", starter: "创作一条 AI 宠物拟人短片:宠物有明确性格和动作,商品以真实结构和正常用法自然参与剧情。", cover: "/assets/video-presets/ai-pet-personification.jpg", previewVideo: "/assets/video-presets/ai-pet-personification.mp4" }, ]; const IMAGE_PRESETS: PresetItem[] = [ @@ -64,19 +62,6 @@ const IMAGE_PRESETS: PresetItem[] = [ { name: "新中式", category: "style", mode: "image", title: "新中式", cover: "/assets/image-presets/neo-chinese.png" }, ]; -const VIDEO_FILTERS = [ - { key: "all", label: "全部" }, - { key: "story", label: "剧情" }, - { key: "commerce", label: "电商" }, - { key: "speaker", label: "口播" }, - { key: "visual", label: "视觉" }, -]; - -const IMAGE_FILTERS = [ - { key: "all", label: "全部" }, - { key: "style", label: "风格" }, -]; - const MENTION_REF_LIMIT = 5; const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [ @@ -134,7 +119,6 @@ export function OmniCreatePage({ const [typeLabels, setTypeLabels] = useState>({}); const [pendingRefs, setPendingRefs] = useState([]); const [previewCase, setPreviewCase] = useState(null); - const [activeCategory, setActiveCategory] = useState("all"); const fileInputRef = useRef(null); const toolsRef = useRef(null); const [starting, setStarting] = useState(false); @@ -155,7 +139,6 @@ export function OmniCreatePage({ setRatio("9:16"); setDuration("智能时长"); } - setActiveCategory("all"); setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev)); }, [outputMode, modelConfigs]); @@ -170,17 +153,13 @@ export function OmniCreatePage({ return () => document.removeEventListener("mousedown", onDown); }, []); - const filters = outputMode === "video" ? VIDEO_FILTERS : IMAGE_FILTERS; const presets = useMemo(() => { - const list = outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS; - if (activeCategory === "all") return list; - return list.filter((item) => item.category === activeCategory); - }, [outputMode, activeCategory]); + return outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS; + }, [outputMode]); const applyPreset = (preset: PresetItem) => { setSelectedCase(preset); - // 图片预设只作为隐藏的生成风格,用户输入区保留自己的创作描述,不把内部 Prompt 展示出来。 - if (preset.mode !== "image" && preset.starter) setPrompt(preset.starter); + // 预设只作为后台的创作方向。无论图片还是视频,都不把内部生成指令塞进用户输入区。 setPreviewCase(null); onNotify?.("info", `已选择预设:${preset.name}`); }; @@ -460,32 +439,12 @@ export function OmniCreatePage({
- {outputMode === "video" ? ( -
- 预设分类 -
- {filters.map((filter) => ( - - ))} -
-
- ) : null} - {outputMode === "image" ? ( -
- 从一个风格开始 - 可选,也可以直接描述你想要的画面。 -
- ) : null} +
+ {outputMode === "video" ? "从一个视频方向开始" : "从一个风格开始"} + {outputMode === "video" ? "可选,也可以直接描述你想拍的视频。" : "可选,也可以直接描述你想要的画面。"} +
{presets.map((card) => (
- 预设示例 - {previewCase.mode === "video" && ( + {previewCase.previewVideo ? ( + + ) : ( + 预设示例 + )} + {previewCase.mode === "video" && !previewCase.previewVideo && ( diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index cbb2d53..7041cbd 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -399,17 +399,6 @@ function collectSessionAssets( }); } - if (message.kind === "prompt_file") { - const title = String(message.payload?.title || "视频生成Prompt.md"); - const body = String(message.payload?.body || ""); - push({ - key: `prompt:${message.id || title}`, - kind: "document", - title, - promptTitle: title, - promptBody: body || "暂无内容", - }); - } } return out; @@ -512,7 +501,7 @@ function PlanCard({ payload }: { payload: Record }) {
视频最终方案 - 请确认后再出 Prompt + 确认后进入出片参数核对
卖点做减法 @@ -555,9 +544,9 @@ function PlanCard({ payload }: { payload: Record }) { ) : null} - 最终输入: + 出片准备: - Prompt.md + {String(payload.ref_count ?? 0)} 组参考素材 + 已整合生成参数与 {String(payload.ref_count ?? 0)} 组参考素材
@@ -565,30 +554,21 @@ function PlanCard({ payload }: { payload: Record }) { ); } -function PromptFileCard({ - payload, - onView, -}: { - payload: Record; - onView: () => void; -}) { +function PromptFileCard({ payload }: { payload: Record }) { return (
- {String(payload.title || "视频生成Prompt.md")} - 已整合策略、镜头、口播、节奏与 {String(payload.ref_count ?? 0)} 组参考素材 + 出片指令已准备好 + 已根据方案、参数和 {String(payload.ref_count ?? 0)} 组参考素材在后台整理完成
-
); } -/** 视频 5 步闸门:策略/方案/Prompt 下方的「按这个继续 / 我想改」。 +/** 视频阶段闸门:策略/方案下方的「按这个继续 / 我想改」。 * 「我想改」在卡片内展开反馈输入,不再依赖底部 composer。 */ function StepConfirmCard({ message, @@ -606,14 +586,14 @@ function StepConfirmCard({ const answers = (message.payload.answers as Record | undefined) || {}; const action = String(answers.step_action || ""); const stepLabel = - step === "strategy" ? "创作策略" : step === "plan" ? "视频方案" : step === "prompt" ? "出片 Prompt" : "这一步"; + step === "strategy" ? "创作策略" : step === "plan" ? "视频方案" : step === "prompt" ? "出片指令" : "这一步"; const placeholder = step === "strategy" ? "说说你想怎么改策略…" : step === "plan" ? "说说你想怎么改方案…" : step === "prompt" - ? "说说你想怎么改 Prompt…" + ? "说说你想怎么改出片细节…" : "说说你想怎么改…"; const [revising, setRevising] = useState(false); const [feedback, setFeedback] = useState(""); @@ -781,21 +761,6 @@ function ElicitCard({ ); } - if (interaction === "chat") { - return ( -
- - - -
-

- {message.text || fields[0]?.label || "这项你想怎么定?"} -

-
-
- ); - } - const toggleMulti = (key: string, value: string) => { setAnswers((prev) => { const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : []; @@ -828,12 +793,12 @@ function ElicitCard({

{message.text || gateLabel}

{!submitted ? ( -
- {(gateField?.options || []).map((option) => ( +
+ {(gateField?.options || []).map((option, index) => ( +
+
+ ) : null} +
+
+ ); + } + return (
@@ -1051,11 +1042,15 @@ function ConfirmCard({ ); } -function ResultCard({ payload }: { payload: Record }) { +function ResultCard({ + payload, +}: { + payload: Record; +}) { const assets = (payload.assets as Array> | undefined) || []; const first = assets[0] || {}; const meta = [payload.model, payload.resolution, payload.ratio].filter(Boolean).join(" · "); - const isVideo = first.type === "video"; + const isGeneratedVideo = first.type === "video"; const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null); return ( @@ -1100,7 +1095,7 @@ function ResultCard({ payload }: { payload: Record }) {
- {isVideo ? "视频已生成" : assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"} + {isGeneratedVideo ? "视频已生成" : assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"} {meta}
@@ -2178,10 +2173,6 @@ export function OmniSessionPage({ setPromptView({ - title: String(message.payload.title || "视频生成Prompt.md"), - body: String(message.payload.body || "暂无内容"), - })} /> ); case "confirm": { @@ -2213,7 +2204,12 @@ export function OmniSessionPage({ case "generating": return ; case "result": - return ; + return ( + + ); default: { const refs = message.refs || []; const body = stripMentionText(message.text, refs);