全能创作与模特库收口:品牌品名输入、确认角色循环、换款落点与鱼眼模板

补充品牌品名改为预填输入框且空骨架不跳步;痛点确认角色后「继续完善方案」可继续推进;点击换款要求每次落点不同;鱼眼换装改用小云雀连环换装 prompt 模板;模特详情标签自动换行。
This commit is contained in:
Azmat@qq.com
2026-09-21 16:47:50 +08:00
parent 0bd1db6bf9
commit 9f15b71f19
6 changed files with 395 additions and 62 deletions
+118 -15
View File
@@ -37,11 +37,14 @@ from .creation_presets import (
format_plot_twist_direction_contract,
apply_video_preset_prompt,
is_click_swap_preset,
is_fish_eye_outfit_preset,
fish_eye_outfit_prompt_template,
plot_twist_story_depth,
plot_twist_story_contract,
preset_guidance,
preset_workflow_guidance,
video_preset_delivery_contract,
scrub_click_swap_same_position_wording,
)
from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions
from .models import CreationConversation, CreationMessage, ModelConfig
@@ -769,12 +772,65 @@ def has_locked_product_reference(conversation: CreationConversation) -> bool:
return len(locked_product_references(conversation)) > 0
PRODUCT_BRAND_EMPTY_TEMPLATE = "品牌是:,商品名是:"
def is_incomplete_product_brand_answer(text: str) -> bool:
"""空模板或只有「品牌是:/商品名是:」骨架、没有真实内容时视为未填完。"""
raw = (text or "").strip()
if not raw:
return True
skeletons = {
PRODUCT_BRAND_EMPTY_TEMPLATE,
"品牌是:,商品名是:",
"品牌是:,品名是:",
"品牌是:,品名是:",
}
if raw in skeletons:
return True
cleaned = raw
for sk in skeletons:
cleaned = cleaned.replace(sk, "")
cleaned = (
cleaned.replace("品牌是:", "")
.replace("品牌是:", "")
.replace("商品名是:", "")
.replace("商品名是:", "")
.replace("品名是:", "")
.replace("品名是:", "")
.replace("", "")
.replace(",", "")
.strip()
)
if not cleaned:
return True
bm = re.search(r"品牌[:是为]\s*([^\n,。!!]+)", raw)
nm = re.search(r"(?:品名|商品名|产品名)[::是为]\s*([^\n,。!!]+)", raw)
if bm or nm:
brand = (bm.group(1).strip() if bm else "")
name = (nm.group(1).strip() if nm else "")
token = re.compile(r"[\u4e00-\u9fffA-Za-z0-9]{2,}")
if not token.search(brand) and not token.search(name) and not token.search(cleaned):
return True
return False
def product_info_needs_confirmation(conversation: CreationConversation, user_text: str = "") -> bool:
"""针对本地上传的商品素材,在对话开始或方案前必须确认品牌与具体品名。"""
if conversation.preset not in _PRODUCT_REQUIRED_PRESETS:
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("product_info_resolved") or memory.get("product_brand") or memory.get("product_name"):
brand = str(memory.get("product_brand") or "").strip()
name = str(memory.get("product_name") or "").strip()
combined = str(memory.get("product_brand_and_name") or "").strip()
token = re.compile(r"[\u4e00-\u9fffA-Za-z0-9]{2,}")
has_real_brand = bool(token.search(brand))
has_real_name = bool(token.search(name))
has_real_combined = bool(combined) and not is_incomplete_product_brand_answer(combined)
if memory.get("product_info_resolved") and (has_real_brand or has_real_name or has_real_combined):
return False
if has_real_brand or has_real_name:
return False
# 如果已锁定的首个商品是正式商品库商品(已自带品牌和品名),无需重复询问
first_prod = next(
@@ -827,7 +883,11 @@ def append_product_info_gate(conversation: CreationConversation) -> CreationMess
"reply_hint": "输入品牌和品名,例如「品牌XX,品名YY」…",
"reply_options": [
{"label": "按图片内容创作", "text": "直接根据图片内容设计品牌与品名,不用再问"},
{"label": "我来补充品牌品名", "text": "品牌是:,商品名是:"},
{
"label": "我来补充品牌品名",
"text": PRODUCT_BRAND_EMPTY_TEMPLATE,
"action": "compose",
},
],
},
)
@@ -923,7 +983,7 @@ def append_click_swap_sequence_gate(conversation: CreationConversation) -> Creat
hint = "先确认要切换的款式和展示顺序。角色会在日常场景中按这个顺序变色换款。"
placeholder = "例如:曜石黑 → 象牙白 → 樱花粉"
else:
hint = "先确认要切换的款式和展示顺序。后续按这个顺序用手指点击触发换款即可,不必强调每次点同一位置"
hint = "先确认要切换的款式和展示顺序。后续按这个顺序用手指点击触发换款;每一次点击落点都要与上一次不同"
placeholder = "例如:黑色 → 白色 → 樱花粉"
return append_message(
conversation,
@@ -1004,7 +1064,7 @@ def apply_click_swap_mode(conversation: CreationConversation, mode: str) -> str:
if value == "finger":
return (
"商家已选择【只出手指出镜】。不要生成角色定妆图,不要追问出镜人物;"
"后续只围绕固定机位 + 手指点击触发换款推进;分镜不要反复写「每次点击同一位置」。"
"后续只围绕固定机位 + 手指点击触发换款推进;每一次换款点击落点必须与上一次不同,禁止写「每次点击同一位置」。"
)
return (
"商家已选择【角色在日常场景中换款】。下一步先锁定出镜角色来源;"
@@ -1927,8 +1987,11 @@ def default_reply_options(
{"label": "从素材里判断", "text": "先根据我上传的素材判断可表达的卖点"},
{"label": "先只突出一个点", "text": "先围绕一个最核心的卖点创作"},
]
# 没有足够语境时只留自由输入,不伪造“继续/改卖点”这种人机按钮
return []
# 已有创作上下文但未命中更具体的追问时,给一个可识别的「继续完善方案」按钮
# 避免只剩文案引导、用户手输后又不算创作意图而卡住。
return [
{"label": "继续完善方案", "text": "继续完善方案"},
]
kind = "短视频" if is_video else "商品图"
return [
{"label": f"帮我做一条{kind}" if is_video else "帮我做一张商品图", "text": f"帮我做一条{kind}" if is_video else "帮我做一张商品图"},
@@ -2010,7 +2073,7 @@ _CREATIVE_INTENT_RE = re.compile(
r"帮我做|帮我拍|帮我出|帮我写|帮我改|帮我生成|"
r"创作[一两]?[条个张]?|创作(?:一条|个|短)?|"
r"做[一两]?[条个张](?:视频|片|图|广告)?|拍[一两]?[条个张](?:视频|片|图|广告)?|"
r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|重写方案|重新写|"
r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|完善方案|继续完善|继续创作|重写方案|重新写|"
r"生成(?:一下|一张|几张|一条)?(?:视频|图|片|广告|脚本)?|做条|做个片|短视频|带货视频|短广告|广告片|带货片|"
r"换卖点|改卖点|换剧情|改剧情|重做|重新出|重新来|再来一次|从头开始|重新做|按这个出|确认出片|"
r"分镜|脚本|口播稿|storyboard|拟人|"
@@ -2047,12 +2110,20 @@ def has_creative_intent(user_text: str, refs: list | None = None) -> bool:
_CONTINUE_INTENT_RE = re.compile(
r"^\s*(继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来)[吧啊呀呢。.!!]*\s*$"
r"^\s*("
r"继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来|"
r"继续完善方案|完善方案|继续创作|继续推进|往下推进|"
r"按当前描述继续|按这个继续|没有其他调整[,,]?按当前描述继续|"
r"使用这[个位只]角色继续创作|使用这[个位只]宠物角色继续创作"
r")[吧啊呀呢。.!]*\s*$"
)
def is_continue_intent(user_text: str) -> bool:
"""已有创作上下文时,这些短句是在授权继续,不是闲聊。"""
"""已有创作上下文时,这些短句是在授权继续,不是闲聊。
含系统引导里的继续完善方案若不识别,用户一点就又回到同一句引导,死循环
"""
return bool(_CONTINUE_INTENT_RE.match((user_text or "").strip()))
@@ -3669,10 +3740,19 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
)
if context.is_video and is_click_swap_preset(conversation.preset) and click_swap_mode(conversation) != "character":
lines.append(
"【点击换款分镜硬性要求】换款由手指点击触发即可;撰写 video_prompt / 方案分镜时,"
"禁止反复写「每次点击同一位置 / 点同一点 / 手指点在完全相同的位置」"
"各镜重点写款式变化与节奏,点击动作点到为止。"
"【点击换款分镜硬性要求】换款由手指点击触发;撰写 video_prompt / 方案分镜时,"
"每一次换款的点击落点必须与上一次不同(点商品的不同区域/角点)"
"禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;"
"各镜要写明本次点击位置不同于上次,并重点写款式变化与节奏。"
)
if context.is_video and is_fish_eye_outfit_preset(conversation.preset):
lines.append(
"【鱼眼换装 Prompt 硬性要求】write_plan / write_prompt 的 video_prompt 必须按小云雀「鱼眼连环换装」模板撰写:"
"时长按套数估算、9:16、超广角鱼眼风格、全片单一机位节拍换装、转场瞬间切装、主体只取五官发型妆容姿态、"
"服装状态机 S0→SN、无对白、转场配快门/whoosh;禁止穿衣过程与口播。"
"不要套用通用「制作级分镜四行+人声」长文模板。"
)
lines.append(fish_eye_outfit_prompt_template())
if context.is_video and is_pet_preset(conversation.preset):
lines.append(
"【宠物拟人台词硬性要求】策略、方案和 video_prompt 里的人声必须是宠物第一人称开口,"
@@ -4125,7 +4205,7 @@ def apply_click_swap_plan_card(
"usp": f"手指点击触发,商品按「{sequence}」连续换款",
"points": [
"固定机位、背景与光线,商品构图稳定",
"换款由手指点击触发,分镜不反复写同一点击位置",
"换款由手指点击触发,每一次点击落点必须与上一次不同",
f"严格按「{sequence}」逐款展示,结尾给出全款式总览",
],
"timeline": [
@@ -4139,13 +4219,13 @@ def apply_click_swap_plan_card(
"start": first_end,
"end": second_end,
"stage": "首次点击换款",
"desc": "手指点击商品触发换款,接触后以干净 match cut 切到下一款;不写点击同一位置",
"desc": "手指点击商品某一区域触发换款,接触后以干净 match cut 切到下一款;本次落点作为基准,后续不得复用同一落点",
},
{
"start": second_end,
"end": third_end,
"stage": "按序连续换款",
"desc": f"按「{sequence}」继续点击触发换款;机位、构图与光线保持稳定,不反复描写点击位置",
"desc": f"按「{sequence}」继续点击触发换款;每一次点击都换到商品的不同区域/角点,机位、构图与光线保持稳定。",
},
{
"start": third_end,
@@ -4676,6 +4756,27 @@ def iter_creation_agent_events(
if stop:
break
# 强制创作回合若模型既没落闸门也没写正文,补一条可点的「继续完善方案」引导,
# 且该文案已被 is_continue_intent 识别,避免用户再点又回到空引导死循环。
if force_creative_turn and not turn_has_gate and last_text_bubble is None:
nudge = append_message(
conversation,
role="assistant",
text=(
"角色已锁定。接下来直接推进创作方案;"
"也可以回复「继续完善方案」让我继续写策略。"
),
payload={
"reply_hint": "可以回复「继续完善方案」,也可以直接说卖点或痛点方向。",
"reply_options": [
{"label": "继续完善方案", "text": "继续完善方案"},
{"label": "先定痛点方向", "text": "先帮我定痛点方向"},
],
},
)
last_text_bubble = nudge
yield {"type": "message", "message": _message_payload(nudge)}
# 收束校验:本轮若只剩散文、没有追问/确认卡,补 reply_hint 或短引导。
for event in ensure_turn_guides(
conversation,
@@ -5169,6 +5270,8 @@ def _dispatch_tool(
f"{video_prompt}\n\n【商家确认的换款顺序】{click_swap_sequence(context.conversation)}\n"
"只允许按这个顺序逐款切换;不得跳序、漏款或自行增加颜色和款式。"
)
if click_swap_mode(context.conversation) != "character":
video_prompt = scrub_click_swap_same_position_wording(video_prompt)
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
video_prompt = apply_product_reality_guard(video_prompt)
video_prompt = apply_clothing_video_guard(context.conversation, video_prompt)
+136 -19
View File
@@ -8,12 +8,14 @@ key 必须是同一个中文名(会话建的时候原样存进 CreationConversat
"""
from __future__ import annotations
import re
CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"})
_CLICK_SWAP_FINGER_GUIDANCE = (
"形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序,"
"画面只围绕同一件商品的逐款切换:机位、背景与灯光稳定,商品构图稳定。"
"用手指点击/轻触触发换款,接触后以干净 match cut 切到下一款。"
"撰写分镜时不要反复写「每次点击同一位置 / 点同一点」;点击动作点到为止,重点写款式变化与节奏"
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点"
"禁止完整人物出镜、禁止生成角色定妆图、禁止改成口播/剧情/使用教程;最后才用同一构图做全款式收束。"
)
_CLICK_SWAP_CHARACTER_GUIDANCE = (
@@ -61,10 +63,12 @@ VIDEO_PRESETS: dict[str, str] = {
"节奏清晰,转场干净。商品外观严格以参考图为准。"
),
"鱼眼换装": (
"鱼眼/广角近距离透视,连续换装节奏。**人物面部和身形必须全程一致**,"
"只有服装在变。人物从第一镜起就已经穿着当前套装;换装用甩头、抬手、转身等触发瞬间切换到已穿好的下一套,"
"禁止拍穿衣服、套袖、拉拉链等穿戴过程"
),
"超广角鱼眼/近距离透视的连环换装短片。"
"全片单一机位、同一构图与背景锁定;画面中心人物清晰、四边缘畸变拉伸,高饱和时尚质感,逐拍卡点剪辑。"
"人物从第一镜起就已经穿着当前套装;换装发生在遮挡/甩头/甩镜等动态瞬间,瞬间切到已穿好的下一套"
"禁止拍穿衣服、套袖、拉拉链等穿戴过程。video_prompt 必须按「鱼眼连环换装」制作模板撰写"
"(时长比例、标题、风格、单机位节拍、转场规则、主体、服装状态机、无对白卡点音效)。"
),
"点击换款": _CLICK_SWAP_GUIDANCE,
"多色商品换款": _CLICK_SWAP_GUIDANCE,
# 旧会话仍会保存「点触换款」,保留同一拍法约束以支持历史继续创作。
@@ -270,10 +274,10 @@ VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
"达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
"鱼眼换装": "优先确认人物参考、服装套数展示顺序;生成前核对脸、身形、场景稳定,只有服装瞬间切换;人物始终已穿好当前套,不写穿衣过程。",
"点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;分镜不要反复写「每次点击同一位置」,禁止转成剧情或口播。",
"多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;分镜不要反复写「每次点击同一位置」,禁止转成剧情或口播。",
"点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;分镜不要反复写「每次点击同一位置」,禁止转成剧情或口播。",
"鱼眼换装": "优先确认人物参考、服装套数N与展示顺序、本条机位(低机位仰拍/高机位俯拍/荷兰角/贴地滑行/手持环绕等择一全片不变)、场景与转场方式;时长按 N×1.5–2.5 秒估算(约 415 秒);write_prompt 严格用「鱼眼连环换装」模板:节拍换装 + 服装状态机,无对白,禁止穿衣过程。",
"点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。",
"多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。",
"点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。",
"探店漫游": "优先确认门店动线与主推项目;用连续移动串联入口、环境、细节和服务,不做碎片化硬切。",
"品牌质感大片": "优先确认品牌气质、材质和商品主卖点;由 Agent 决定光线和镜头,不把专业选择反复抛给用户。",
"前后对比实测": "优先确认同一对象的前、中、后素材或可比较条件;生成前核对对比不夸大且没有缺失关键阶段。",
@@ -313,15 +317,23 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
"最后以干净的产品收束。包装文字、颜色、比例和结构全程稳定,不能用 AI 生成的相似商品替代参考图。"
),
"鱼眼换装": (
"【预设执行层·鱼眼换装】使用近距离鱼眼/广角透视和稳定的同一机位;人物脸、身形、发型、场景、光线连续一致"
"人物始终已穿着当前套装;每一套由甩头、抬手、转身等动作触发瞬间切换到已穿好的下一套,按用户提供顺序完整展示"
"禁止穿衣、套袖、扣扣、拉拉链等穿戴过程镜头;镜头变化来自触发动作与节奏,不额外编复杂营销剧情"
),
"【预设执行层·鱼眼换装·小云雀模板】生成时尚换装短视频"
"比例默认 9:16;时长按服装套数 N 估算(每套约 1.5–2.5 秒,通常 4–15 秒)"
"风格:超广角鱼眼,中心清晰、四边缘明显畸变拉伸,高饱和,时尚质感,逐拍卡点剪辑"
"镜头:全片单一机位(本条自选一种并整条不变:低机位仰拍 / 高机位俯拍 / 荷兰角倾斜 / 贴地滑行 / 手持环绕);"
"0–1 秒该机位对准主体、第 1 套造型定格 pose;之后逐拍换装,机位始终不变,仅服装与动作逐拍更新;最后一拍同机位定格收尾。"
"转场:每次换装发生在遮挡/甩镜动态瞬间(抬手遮挡镜头 / 甩头转身划过画面 / 镜头快速甩动出虚影);"
"转场瞬间服装切换,机位保持原位,下一拍回到同一构图。"
"主体:只采用参考图五官、发型、妆容、姿态,不采用参考图服装与背景。"
"服装状态机:S0 造型1 → 转场动作 → S1 造型2 → … → SN 定格收尾;"
"全过程人物身份/体型/发型一致,机位/构图/背景锁定,仅服装逐拍变化;人物始终已穿好当前套,禁止穿戴过程镜头。"
"声音:无对白,预留卡点 BGM,每次转场配快门/whoosh 音效。"
),
"点击换款": (
"【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。"
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
"分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化"
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
),
@@ -329,7 +341,7 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
"【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。"
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
"分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化"
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
),
@@ -337,7 +349,7 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
"【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。"
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
"分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化"
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
),
@@ -403,7 +415,9 @@ _CLICK_SWAP_FINGER_CONTRACT = (
"【预设执行层·点击换款·只出手】这不是口播片、剧情片或普通商品展示片。"
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
"分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化。"
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),"
"禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;"
"分镜与 video_prompt 要体现「本次点击位置不同于上次」。"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤、不生成角色定妆图。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
)
@@ -425,10 +439,102 @@ def video_preset_delivery_contract(name: str, *, click_swap_mode: str = "") -> s
return VIDEO_PRESET_DELIVERY_CONTRACTS.get(preset, "")
FISH_EYE_OUTFIT_PRESET = "鱼眼换装"
_FISH_EYE_OUTFIT_PROMPT_TEMPLATE = """
【鱼眼连环换装 · video_prompt 强制模板(来自小云雀写法)】
当预设为「鱼眼换装」时,write_plan / write_prompt 的 video_prompt 必须按下面骨架填写,
用会话里真实的人物、服装套数、机位、场景与顺序替换【】占位;不要改回通用「制作级分镜四行」长文,也不要编口播。
时长:【4-15秒,按N推算,每套服装约1.5-2.5秒】秒
比例:9:16
任务:生成时尚换装短视频。
标题:《鱼眼连环换装·【机位名】版》
风格与视觉参考:
超广角鱼眼镜头风格,画面中心人物清晰、四边缘明显畸变拉伸,
高饱和色彩,时尚质感,逐拍卡点剪辑。
镜头语言:
全片单一机位:【本条视频机位描述,如:低机位仰拍 / 高机位俯拍 /
荷兰角倾斜 / 贴地滑行 / 手持环绕——每条视频自选一种,整条不变】
0-1秒:该机位对准【主体】,第1套造型定格pose
之后逐拍换装,机位始终不变,仅服装与动作逐拍更新:
节拍1:造型1,随节拍做动作
节拍2:造型2,随节拍做动作
节拍3:造型3,随节拍做动作
……按N套延伸
最后一拍:同机位稳定构图,定格收尾pose
转场规则:每次换装发生在遮挡/甩镜的动态瞬间——
【转场方式:抬手遮挡镜头 / 甩头转身划过画面 / 镜头快速甩动出虚影,
转场瞬间服装切换,机位保持原位,下一拍回到同一构图】
主体:
主体1:【人物描述】,对应@图片1
(只采用五官、发型、妆容、姿态,不采用服装与背景)
服装状态机:
S0 造型1:【服装1描述】
→ 动作:【转场方式】
→ S1 造型2:【服装2描述】
→ 动作:【转场方式】
→ ……按N套延伸
→ SN 造型N:【服装N描述】定格收尾
全过程不变量:人物身份、体型、发型全程一致;
机位、构图、背景全程锁定不变,仅服装逐拍变化;
人物始终已穿好当前套,禁止穿衣/套袖/拉拉链等穿戴过程镜头。
声音:无对白,预留卡点BGM位,每次转场配快门/whoosh音效
全片保持主体1身份一致、鱼眼畸变风格统一、
机位与构图全片同一,背景【场景描述】不变。
"""
def is_fish_eye_outfit_preset(name: str) -> bool:
return (name or "").strip() == FISH_EYE_OUTFIT_PRESET
def fish_eye_outfit_prompt_template() -> str:
return _FISH_EYE_OUTFIT_PROMPT_TEMPLATE.strip()
def is_click_swap_preset(name: str) -> bool:
return (name or "").strip() in CLICK_SWAP_PRESETS
_SAME_CLICK_POS_RE = re.compile(
r"(?:每次(?:都)?)?(?:点击|点触|轻触|手指(?:点击|点)?)(?:在|到)?(?:完全)?(?:相?同的?)?(?:一个位置|一(?:个)?位置|一点|同一处|同一点|同一个位置)"
r"|每次点击同一位置"
r"|点同一点"
r"|手指点在完全相同的位置"
r"|手指始终点同一"
r"|点击位置保持不变"
r"|固定点击位置"
r"|不写点击同一位置"
r"|不必强调每次点同一位置"
)
def scrub_click_swap_same_position_wording(text: str) -> str:
"""去掉提示词里「每次点击同一位置」类表述,并在缺失时补上「每次点击位置不同」。"""
raw = str(text or "")
if not raw:
return raw
cleaned = _SAME_CLICK_POS_RE.sub("", raw)
cleaned = re.sub(r"[,。;]{2,}", "", cleaned)
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
if "点击位置必须不同" not in cleaned and "落点必须与上一次不同" not in cleaned and "落点必须不同于上一次" not in cleaned:
cleaned = (
f"{cleaned}\n\n【点击落点硬性要求】每一次换款的手指点击落点必须与上一次不同"
"(点商品的不同区域/角点),禁止「每次点击同一位置 / 点同一点」。"
).strip()
return cleaned
def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "") -> str:
"""把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。"""
base = str(prompt or "").strip()
@@ -450,17 +556,28 @@ def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str =
"【最终执行检查】全片锁定同一位角色与同一生活场景;"
"商品颜色/款式严格按确认顺序切换,禁止随机换人、口播带货腔或复杂剧情反转。"
)
base = scrub_click_swap_same_position_wording(base)
return (
f"{marker}\n{contract}\n\n"
"【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式和顺序事实;"
"下文任何与「固定机位、手指点击触发换款、构图稳定」冲突的拍法一律忽略。\n"
f"{base}\n\n"
"【最终执行检查】换款由手指点击触发,机位、背景、光线与商品构图保持稳定;"
"分镜禁止反复写「每次点击同一位置 / 点同一点」;不出现完整人物、不生成角色图。"
"每一次换款点击落点必须不同于上一次,禁止「每次点击同一位置 / 点同一点」;"
"不出现完整人物、不生成角色图。"
)
if is_fish_eye_outfit_preset(preset):
return (
f"{marker}\n{contract}\n\n"
"【原始人物与服装信息】仅提取下文中的人物身份、服装套数/顺序、机位、场景与转场事实;"
"下文任何与「全片单机位、鱼眼畸变、节拍换装、服装状态机、无对白」冲突的拍法一律忽略。\n"
f"{base}\n\n"
"【最终执行检查】全片单一机位与构图锁定;鱼眼中心清晰、边缘畸变;"
"换装发生在遮挡/甩镜瞬间且人物始终已穿好当前套;无对白,转场配快门/whoosh;"
"禁止穿衣过程与口播带货腔。"
)
return f"{base}\n\n{marker}\n{contract}"
def apply_image_preset_prompt(name: str, prompt: str) -> str:
"""将已选图片预设的风格字段确定性并入实际出图指令,不依赖 Agent 自行复述。"""
base = str(prompt or "").strip()
+23 -8
View File
@@ -2511,7 +2511,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertIn("手指轻触/点击", prompt)
self.assertIn("不演剧情", prompt)
self.assertIn("干净 match cut", prompt)
self.assertIn("不要反复写「每次点击同一位置", prompt)
self.assertIn("每一次换款的手指点击落点必须与上一次不同", prompt)
def test_personified_product_uses_offscreen_voice_without_changing_packaging(self):
self.conversation.preset = "商品拟人广告"
@@ -2891,8 +2891,10 @@ class PresetGuidanceTests(CreationAgentBaseTests):
text="开始", model_config=self.model))
system = fake.calls[0]["messages"][0]["content"]
self.assertIn("鱼眼换装", system)
# 光有名字模型只能靠猜,拍法约束必须一起给
self.assertIn("人物面部和身形必须全程一致", system)
# 光有名字模型只能靠猜,拍法约束必须一起给(小云雀连环换装模板)
self.assertIn("小云雀", system)
self.assertIn("服装状态机", system)
self.assertIn("超广角鱼眼", system)
def test_product_personification_defaults_to_faceless_expression(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="拟人测试商品")
@@ -2943,7 +2945,7 @@ class PresetGuidanceTests(CreationAgentBaseTests):
self.assertIn("当前预设的工作重点", system)
self.assertIn("确认颜色/款式/SKU 和切换顺序", system)
self.assertIn("固定机位 + 手指点击触发换款", system)
self.assertIn("不要反复写「每次点击同一位置」", system)
self.assertIn("每一次换款的点击落点必须与上一次不同", system)
self.assertIn("禁止转成剧情或口播", system)
def test_video_preset_is_injected_into_the_actual_generation_prompt(self):
@@ -2951,8 +2953,10 @@ class PresetGuidanceTests(CreationAgentBaseTests):
prompt = apply_video_preset_prompt("鱼眼换装", "人物抬手完成连续换装")
self.assertIn("【视频预设】鱼眼换装", prompt)
self.assertIn("近距离鱼眼/广角透视", prompt)
self.assertIn("人物脸、身形、发型、场景、光线连续一致", prompt)
self.assertIn("小云雀模板", prompt)
self.assertIn("服装状态机", prompt)
self.assertIn("超广角鱼眼", prompt)
self.assertIn("无对白", prompt)
# 出片确认、重试等多次经过此处,规则只写一次。
self.assertEqual(apply_video_preset_prompt("鱼眼换装", prompt), prompt)
@@ -2970,7 +2974,7 @@ class PresetGuidanceTests(CreationAgentBaseTests):
self.assertIn("原始商品与 SKU 信息", prompt)
self.assertIn("冲突的拍法一律忽略", prompt)
self.assertIn("换款由手指点击触发", prompt)
self.assertIn("分镜禁止反复写「每次点击同一位置", prompt)
self.assertIn("每一次换款点击落点必须不同于上一次", prompt)
self.assertEqual(apply_video_preset_prompt("点击换款", prompt), prompt)
def test_every_video_preset_has_a_delivery_contract(self):
@@ -3267,6 +3271,7 @@ class CreativeIntentTests(SimpleTestCase):
for text in (
"做条带货视频",
"帮我出个方案",
"继续完善方案",
"重新写方案",
"出一张主图",
"拍一张自然、有温度的生活方式照片",
@@ -3276,7 +3281,17 @@ class CreativeIntentTests(SimpleTestCase):
self.assertTrue(has_creative_intent(text), text)
def test_short_continue_phrases_are_explicit_continuations(self):
for text in ("继续", "接着做", "往下做吧", "就按这个", "直接来"):
for text in (
"继续",
"接着做",
"往下做吧",
"就按这个",
"直接来",
"继续完善方案",
"完善方案",
"继续创作",
"使用这个角色继续创作",
):
self.assertTrue(is_continue_intent(text), text)
for text in ("好的", "", "先不用", "今天怎么样"):
+70 -17
View File
@@ -58,6 +58,8 @@ from .creation_agent import (
submit_confirmed_image,
submit_confirmed_video,
submit_generated_person_reference,
is_incomplete_product_brand_answer,
PRODUCT_BRAND_EMPTY_TEMPLATE,
)
from .tasks import run_creation_agent_turn_task
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
@@ -2237,6 +2239,20 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
if fields:
field = fields[0]
if payload.get("topic") == "product_info":
clean_probe = text.strip()
design_from_image = any(
kw in clean_probe for kw in ("按图片", "设计品牌", "不用再问")
)
if (not design_from_image) and is_incomplete_product_brand_answer(clean_probe):
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"detail": "请填写品牌和品名后再发送,例如「品牌浅蓝小熊,品名婴儿柔湿巾」。",
"messages": [CreationMessageSerializer(pending).data],
}, status=200)
answers = {
str(field.get("key") or "answer"):
_chat_answer_for_field(field, text, conversation.team)
@@ -2270,8 +2286,20 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
elif payload.get("topic") == "product_info":
clean_ans = text.strip()
memory = dict(conversation.memory or {})
memory["product_info_resolved"] = True
if clean_ans and not any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")):
design_from_image = any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问"))
if design_from_image:
memory["product_info_resolved"] = True
continuation_instruction = (
"用户要求直接根据图片内容设计品牌与品名推进创作。"
"直接基于图片外观特征推进方案,不要重复追问品牌。"
)
elif is_incomplete_product_brand_answer(clean_ans):
memory.pop("product_info_resolved", None)
continuation_instruction = (
"用户尚未填写完整品牌与品名。请继续请他补充,不要进入下一步。"
)
else:
memory["product_info_resolved"] = True
memory["product_brand_and_name"] = clean_ans
bm = re.search(r"品牌[:是为]?\s*([^\n,。!!]+)", clean_ans)
nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,。!!]+)", clean_ans)
@@ -2284,9 +2312,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
r["name"] = clean_ans
break
conversation.pinned_refs = conversation.pinned_refs
continuation_instruction = f"用户已提供商品品牌与品名:【{clean_ans}】。直接围绕该商品推进方案,不要重复追问。"
else:
continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌"
continuation_instruction = (
f"用户已提供商品品牌与品名:【{clean_ans}】。"
"直接围绕该商品推进方案,不要重复追问。"
)
conversation.memory = memory
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
# 保留用户原文气泡,便于回看刚填的品牌/品名;不要清空 text,
@@ -2331,8 +2360,11 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
conversation.save(update_fields=["memory", "updated_at"])
force_creative_turn = True
continuation_instruction = (
"用户已确认使用当前生成的角色出镜。直接基于该角色继续推进创作方案"
"(若尚未选商品则确定商品,已选好商品则开始写创作策略和方案),不要再次追问角色来源。"
"用户已确认使用当前生成的角色出镜。"
"本轮必须实质推进创作:若还缺商品则 ask_user 选商品;"
"痛点解决演示若还缺痛点方向则 ask_userpain_point_direction);"
"信息够了就立刻 write_strategy。"
"禁止只回一句「可以回复继续完善方案」之类的空引导,也不要再次追问角色来源。"
)
elif is_platform_person and (regenerate_button or (person_confirm_pending and clean_text and not upload_person)):
prev_prompt = str(memory_now.get("person_prompt") or "").strip()
@@ -2662,24 +2694,45 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
elif payload.get("topic") == "product_info":
ans = str(answers.get("product_brand_and_name") or "").strip()
memory = dict(conversation.memory or {})
memory["product_info_resolved"] = True
clean_ans = ans.replace("品牌是:,商品名是:", "").strip()
if clean_ans and not any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")):
memory["product_brand_and_name"] = clean_ans
bm = re.search(r"品牌[:是为]?\s*([^\n,。!!]+)", clean_ans)
nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,。!!]+)", clean_ans)
design_from_image = any(kw in ans for kw in ("按图片", "设计品牌", "不用再问"))
clean_ans = ans.replace(PRODUCT_BRAND_EMPTY_TEMPLATE, "").strip()
if (not design_from_image) and is_incomplete_product_brand_answer(ans):
payload["submitted"] = False
payload["answers"] = {}
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"detail": "请填写品牌和品名后再发送,例如「品牌浅蓝小熊,品名婴儿柔湿巾」。",
"messages": [CreationMessageSerializer(pending).data],
}, status=200)
if design_from_image:
memory["product_info_resolved"] = True
continuation_instruction = (
"用户要求直接根据图片内容设计品牌与品名推进创作。"
"直接基于图片外观特征推进方案,不要重复追问品牌。"
)
else:
memory["product_info_resolved"] = True
memory["product_brand_and_name"] = clean_ans or ans
bm = re.search(r"品牌[:是为]?\s*([^\n,。!!]+)", clean_ans or ans)
nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,。!!]+)", clean_ans or ans)
if bm:
memory["product_brand"] = bm.group(1).strip()
if nm:
memory["product_name"] = nm.group(1).strip()
for r in (conversation.pinned_refs or []):
if isinstance(r, dict) and r.get("type") in ("asset", "product"):
r["name"] = clean_ans
r["name"] = clean_ans or ans
break
conversation.pinned_refs = conversation.pinned_refs
continuation_instruction = f"用户已提供商品品牌与品名:【{clean_ans}】。直接围绕该商品推进方案,不要重复追问。"
else:
continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌"
continuation_instruction = (
f"用户已提供商品品牌与品名:【{clean_ans or ans}】。"
"直接围绕该商品推进方案,不要重复追问。"
)
conversation.memory = memory
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
text = ""
+8
View File
@@ -318,9 +318,17 @@
.model-detail-portrait[role="button"] { cursor: zoom-in; }
.model-detail-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
min-width: 0;
max-width: 100%;
}
.model-detail-tags .pill {
flex: 0 1 auto;
max-width: 100%;
overflow-wrap: anywhere;
}
.model-detail-triview {
width: 100%;
+40 -3
View File
@@ -48,7 +48,7 @@ type PromptBlock =
| { type: "shot"; heading: string; fields: Array<{ label: string; value: string }>; text: string }
| { type: "text"; heading: string; text: string };
type ReplyOption = { label: string; text: string; action?: "upload" };
type ReplyOption = { label: string; text: string; action?: "upload" | "compose" };
function renderInlineMarkdown(text: string): ReactNode[] {
return text.split(/(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)/g).filter(Boolean).map((part, index) => {
@@ -293,11 +293,18 @@ function replyOptionsForMessage(text: string, payload: Record<string, unknown>):
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object")
.map((item) => {
const label = String(item.label || "").trim();
const rawAction = String(item.action || "").trim();
const compose = rawAction === "compose" || /我来补充/.test(label);
return {
label,
text: String(item.text || "").trim(),
// 任何引导里的“上传图片/实物图/素材图”都走本地文件选择器,避免同一套对话里有的能传、有的只是文字回复。
action: /上传.*(?:图|图片|素材)|(?:图|图片|素材).*上传/.test(label) ? "upload" as const : undefined,
// compose:只预填输入框,不直接提交空骨架。
action: /上传.*(?:图|图片|素材)|(?:图|图片|素材).*上传/.test(label)
? "upload" as const
: compose
? "compose" as const
: undefined,
};
})
.filter((item) => item.label && item.text)
@@ -1676,6 +1683,7 @@ function ElicitCard({
options: replyOptions.map((item, index) => ({
value: String(item.text || item.value || item.label || `option_${index + 1}`),
label: String(item.label || item.text || item.value || `选项 ${index + 1}`),
action: String((item as { action?: string }).action || "").trim() || (/我来补充/.test(String(item.label || "")) ? "compose" : ""),
})),
}
: null;
@@ -1722,6 +1730,17 @@ function ElicitCard({
event.preventDefault();
const text = chatAnswer.trim();
if (!text || disabled) return;
// 品牌品名空骨架未填完:留在输入框,不提交。
if (
String(message.payload.topic || "") === "product_info"
&& (/^品牌是[:]\s*\s*(?:商品名|品名)是[:]\s*$/.test(text) || text === "品牌是:,商品名是:")
) {
requestAnimationFrame(() => {
const input = document.querySelector(".omni-chat-question-input input") as HTMLInputElement | null;
input?.focus();
});
return;
}
onChatAnswer(text);
setChatAnswer("");
};
@@ -1753,6 +1772,20 @@ function ElicitCard({
onClick={() => {
// reply_options 是快捷整句回答,走聊天正文;字段 options 仍走 elicit answers。
if (replyChoiceField && !fieldChoice) {
const action = typeof option === "string" ? "" : String(option?.action || "");
// 「我来补充…」:预填输入框,等用户填完再发,禁止空骨架直接提交。
if (action === "compose" || /我来补充/.test(label) || /^品牌是[:]\s*?\s*(?:商品名|品名)是[:]\s*$/.test(value)) {
setChatAnswer(value.includes("品牌是") ? value : "品牌是:,商品名是:");
requestAnimationFrame(() => {
const input = document.querySelector(".omni-chat-question-input input") as HTMLInputElement | null;
input?.focus();
if (input && input.value.includes(":,")) {
const pos = input.value.indexOf("") + 1;
input.setSelectionRange(pos, pos);
}
});
return;
}
onChatAnswer(value);
return;
}
@@ -1772,7 +1805,11 @@ function ElicitCard({
value={chatAnswer}
disabled={disabled}
onChange={(event) => setChatAnswer(event.target.value)}
placeholder="输入你的想法…"
placeholder={
String(message.payload.topic || "") === "product_info"
? "例如:品牌浅蓝小熊,品名婴儿柔湿巾"
: "输入你的想法…"
}
aria-label="回答这个问题"
/>
<button type="submit" aria-label="发送回答" disabled={disabled || !chatAnswer.trim()}>