优化全能创作
Deploy dev / deploy (push) Successful in 39s

This commit is contained in:
Azmat@qq.com
2026-09-29 15:15:54 +08:00
parent 1130ecd4ba
commit c687ec4bbd
16 changed files with 632 additions and 258 deletions
+7 -5
View File
@@ -648,12 +648,12 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
model_ids.append(model_id)
memory["person_cast_model_ids"] = model_ids
memory["person_model_id"] = model_id
pending = int(memory.get("person_cast_pending") or cast_total or 1)
pending = int(memory.get("person_cast_pending") or 1)
pending = max(0, pending - 1)
memory["person_cast_pending"] = pending
memory["person_cast_total"] = int(memory.get("person_cast_total") or cast_total or 1)
if pending > 0:
# 还有其它角色定妆图在生成:继续等待,不打断用户确认
# 兼容历史上已经并发提交的角色任务。
memory["person_source_pending"] = True
memory.pop("person_source_ready", None)
memory.pop("person_confirm_pending", None)
@@ -667,7 +667,8 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
return message
memory["person_source_pending"] = False
memory["person_source_ready"] = True
from .creation_agent import locked_person_references
memory["person_source_ready"] = len(locked_person_references(conversation)) >= cast_total
memory["person_confirm_pending"] = True
conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING
@@ -684,9 +685,10 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
upload = "我上传宠物参考图"
upload_label = "上传其他宠物"
elif total > 1:
identity = str(original_payload.get("cast_role_identity") or f"第{cast_index}位出镜角色")
confirm_text = (
f"已生成 {total} 位出镜角色定妆图。长视频会按这些图分别锁脸,"
"避免前后形象漂移。你看这组角色合适吗?是否使用这些角色继续创作?"
f"第 {cast_index}/{total} 位【{identity}】的角色图已生成。"
"你看这位角色合适吗?确认后再准备下一位;所有角色都会分别锁脸。"
)
reply_hint = "回复「使用这个角色」继续,或说明想调整哪一位…"
regen = "重新生成一个角色"
+223 -85
View File
@@ -30,7 +30,6 @@ from django.db import transaction
from .creation import append_message, pin_refs
from .creation_presets import (
PLOT_TWIST_PRESET,
PLOT_TWIST_STORY_DEPTH_OPTIONS,
apply_image_preset_prompt,
apply_plot_twist_story_contract,
apply_plot_twist_direction_contract,
@@ -139,7 +138,7 @@ TRUNCATION_RETRY_INSTRUCTION = (
"上一次输出撞到长度上限被截断,工具参数不是完整 JSON,本次作废。"
"请立刻重新调用同一个工具:先写 usp / points / timeline,再写 video_prompt;"
"video_prompt 压到更紧凑的篇幅(长视频只写章节结构、每个 30 秒段 3–5 个关键镜头和段尾交接状态),"
"不要复述已写过的规则,不要输出工具调用以外的解释文字。"
"短剧仍要保留开端至结局的完整故事线和全时长分镜;不要复述已写过的规则,不要输出工具调用以外的解释文字。"
)
TRUNCATION_GIVE_UP_NOTICE = (
@@ -389,16 +388,19 @@ def active_plot_twist_story_depth(conversation: CreationConversation) -> str:
if from_duration and from_duration["value"] != "smart":
return str(from_duration["value"])
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return str(memory.get("plot_twist_story_depth") or "").strip()
depth = str(memory.get("plot_twist_story_depth") or "").strip()
return depth if depth in {"15s", "30s", "60s"} else ""
def set_plot_twist_story_depth(conversation: CreationConversation, value: str) -> dict | None:
"""卡片或自然语言选时长后,同步会话顶部参数与最终 Prompt 的故事结构。"""
"""同步会话时长与短剧结构;旧「智能推荐」入口归一为 60 秒。"""
if not is_plot_twist_conversation(conversation):
return None
depth = plot_twist_story_depth(value)
if depth is None:
return None
if depth["value"] == "smart":
depth = plot_twist_story_depth("60s")
memory = dict(conversation.memory or {})
params = dict(conversation.params or {})
memory["plot_twist_story_depth"] = depth["value"]
@@ -410,31 +412,59 @@ def set_plot_twist_story_depth(conversation: CreationConversation, value: str) -
return depth
def append_plot_twist_story_depth_question(conversation: CreationConversation) -> CreationMessage:
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="你希望这支剧情带货视频做到什么程度?",
payload={
"interaction": "plot_twist_story_depth",
"fields": [
{
"key": "story_depth",
"label": "故事深度选择",
"type": "single",
"required": True,
"options": [
{"value": item["value"], "label": f"{item['label']}|{item['summary']}"}
for item in PLOT_TWIST_STORY_DEPTH_OPTIONS
if item["value"] in {"15s", "30s", "60s"}
],
}
],
"submitted": False,
"answers": {},
},
_PLOT_TWIST_STORY_BEATS = ("开端", "冲突", "升级", "反转", "结局")
def plot_twist_storyline_issue(
video_prompt: str,
*,
timeline: list[dict] | None = None,
duration: int = 60,
) -> str:
"""短剧在展示给用户前检查完整故事骨架,防止只交出其中一小段。"""
beats = _PLOT_TWIST_STORY_BEATS if duration >= 45 else ("开端", "冲突", "反转", "结局")
minimum_beats = len(beats)
minimum_detail = 12 if duration >= 45 else 8
prompt = str(video_prompt or "")
if re.search(r"未完待续|下集(?:再|见|继续)|结尾留悬念", prompt):
return "短剧应在本片交代结局,不能停在未完待续或下集悬念。"
if "完整故事线" not in prompt:
return f"视频 Prompt 缺少【完整故事线】:先写{'、'.join(beats)},再展开逐镜脚本。"
last_position = -1
for beat in beats:
match = re.search(
rf"(?m)^[ \t]*(?:[-*][ \t]*)?{beat}(?:[ \t]*[((][^))]{{0,25}}[))])?[ \t]*[::][ \t]*(.+)$",
prompt,
)
if not match or match.start() <= last_position or len(re.sub(r"\s", "", match.group(1))) < minimum_detail:
return f"【完整故事线】中的「{beat}」需要按顺序写出具体人物行动、因果和结果,不能只有标题或一句概括。"
last_position = match.start()
prompt_spans = sorted({
(float(match.group(1)), float(match.group(2)))
for match in _SMART_DURATION_RE.finditer(prompt)
})
if len(prompt_spans) < minimum_beats or prompt_spans[0][0] > 1 or max(end for _, end in prompt_spans) < duration - 2:
return f"视频 Prompt 的分镜须从 0 秒连续写到 {duration} 秒结局,不能只描述故事中间的一场戏。"
covered_until = prompt_spans[0][1]
for start, end in prompt_spans[1:]:
if start - covered_until > 2:
return "视频 Prompt 的分镜中间有时间空档,请补全人物行动与因果承接。"
covered_until = max(covered_until, end)
if timeline is not None:
spans: list[tuple[float, float]] = []
for item in timeline:
try:
start, end = float(item["start"]), float(item["end"])
except (KeyError, TypeError, ValueError):
continue
if end > start:
spans.append((start, end))
spans.sort()
if len(spans) < minimum_beats or not spans or spans[0][0] > 1 or spans[-1][1] < duration - 2:
return f"方案时间轴须从故事起因覆盖到 {duration} 秒结局,至少分 {minimum_beats} 段承接,不能只写中间片段。"
if any(next_start - end > 2 for (_, end), (next_start, _) in zip(spans, spans[1:])):
return "方案时间轴中间有故事断档;请补全前后动作和因果承接。"
return ""
def _plot_twist_direction_fallback(conversation: CreationConversation) -> list[dict]:
@@ -1162,7 +1192,8 @@ def append_person_source_gate(conversation: CreationConversation, extra_text: st
product_name = str(memory.get("product_name") or memory.get("product_brand_and_name") or "").strip()
is_pet = is_pet_preset(conversation.preset)
missing_cast = 1
role_identity = ""
default_person_prompt = ""
if is_pet:
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪只宠物角色出镜?选定后,所有镜头和分段都会锁定同一只宠物形象。"
@@ -1174,21 +1205,21 @@ def append_person_source_gate(conversation: CreationConversation, extra_text: st
else:
cast_needed = infer_needed_cast_count(conversation, extra_text)
cast_have = len(locked_person_references(conversation))
missing_cast = max(1, cast_needed - cast_have)
cast_index = min(cast_needed, cast_have + 1)
role_identity, default_person_prompt = cast_role_profile(conversation, cast_index, cast_needed)
if cast_needed > 1:
prompt_text = (
f"商品已选定【{product_name}】。这条视频需要 {cast_needed} 位出镜人物,"
f"当前补全第 {cast_index}/{cast_needed} 位;所有镜头和分段都会按角色图锁脸。"
f"先补第 {cast_index}/{cast_needed} 位:{role_identity}。确认这位角色后再准备下一位;所有镜头和分段都会按角色图锁脸。"
if product_name else
f"这条视频需要 {cast_needed} 位出镜人物,当前补全第 {cast_index}/{cast_needed} 位。"
"所有镜头和分段都会按角色图锁脸,避免前后形象漂移。"
f"这条视频需要 {cast_needed} 位出镜人物。先补第 {cast_index}/{cast_needed} 位:{role_identity}。"
"确认这位角色后再准备下一位,避免前后形象漂移。"
)
else:
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。"
f"商品已选定【{product_name}】。本片出镜角色:{role_identity}。请选择角色来源;选定后,所有镜头和分段都会锁定同一位人物。"
if product_name else
"先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。"
f"本片出镜角色:{role_identity}。请先确定角色来源;选定后,所有镜头和分段都会锁定同一位人物。"
)
field_label = "选择人物来源"
library_label = "从模特库选择"
@@ -1203,7 +1234,9 @@ def append_person_source_gate(conversation: CreationConversation, extra_text: st
"is_pet": is_pet,
"cast_needed": 1 if is_pet else infer_needed_cast_count(conversation, extra_text),
"cast_have": len(locked_person_references(conversation)),
"estimated_credits": estimate_role_image_credits(conversation, missing_cast),
"role_identity": role_identity,
"default_person_prompt": default_person_prompt,
"estimated_credits": estimate_role_image_credits(conversation, 1),
"fields": [{
"key": "person_source",
"label": field_label,
@@ -1418,14 +1451,6 @@ def infer_needed_cast_count(conversation: CreationConversation, extra_text: str
mapping = {"1": 1, "2": 2, "3": 3, "4": 4, "一": 1, "二": 2, "三": 3, "四": 4}
needed = max(needed, max(mapping.get(item, 1) for item in role_labels))
split_roles = [
part.strip()
for part in re.split(r"(?:^|\n)\s*(?:角色|人物)\s*[1-4一二三四][::、..\s]", blob)
if part.strip()
]
if len(split_roles) >= 2:
needed = max(needed, min(4, len(split_roles)))
return max(1, min(4, needed))
@@ -1508,6 +1533,54 @@ def _shared_cast_gender_from_text(text: str, total: int) -> str:
return ""
_ROLE_IDENTITIES = (
"母亲", "妈妈", "女儿", "父亲", "爸爸", "儿子", "姐姐", "妹妹", "哥哥", "弟弟",
"女主", "男主", "闺蜜", "空乘", "旅客", "顾客", "店员", "同事", "朋友", "达人", "主播",
)
def cast_role_profile(conversation: CreationConversation, index: int, total: int) -> tuple[str, str]:
"""从已确认的视频 Prompt 提取当前角色身份,并给单张定妆图预填可编辑的外观。"""
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
script = str(memory.get("pending_video_prompt") or "")
context = script or _conversation_cast_text_blob(conversation)
numbered = re.findall(
r"(?:角色|人物)\s*([1-4一二三四])\s*[::、..]\s*([^\n;;。]{2,160})",
script,
)
number_map = {"1": 1, "2": 2, "3": 3, "4": 4, "一": 1, "二": 2, "三": 3, "四": 4}
clause = next((body.strip() for label, body in numbered if number_map.get(label) == index), "")
relation = next((pair for word, pair in (
("母女", ("母亲", "女儿")), ("父女", ("父亲", "女儿")),
("母子", ("母亲", "儿子")), ("父子", ("父亲", "儿子")),
("姐妹", ("姐姐", "妹妹")), ("兄弟", ("哥哥", "弟弟")),
("男女主", ("男主", "女主")),
) if word in context), ())
explicit_identity = next((word for word in _ROLE_IDENTITIES if word in clause), "")
if explicit_identity:
identity = explicit_identity
elif relation and index <= len(relation):
identity = relation[index - 1]
else:
gender = _cast_gender_from_text(clause) or _shared_cast_gender_from_text(context, total)
identity = f"第{index}位成年{gender}角色" if gender else ("出镜主角" if total == 1 else f"第{index}位出镜人物")
gender = _cast_gender_from_text(clause) or _cast_gender_from_text(identity) or _shared_cast_gender_from_text(context, total)
# 只取角色外观,不把动作、商品或分镜内容带入定妆图。
appearance = re.split(r"(?:在|展示|拿着|手持|使用|进入|走向|镜头|商品|产品|画面|场景)", clause, maxsplit=1)[0]
appearance = _character_only_appearance_text(appearance).strip(",,。;; ")
if appearance and len(appearance) <= 80:
default = appearance
else:
default = f"成年{gender},{identity}" if gender else f"成年人物,{identity}"
if identity not in default:
default = f"{identity},{default}"
if gender and gender not in default and not re.search(_FEMALE_CAST_WORDS if gender == "女性" else _MALE_CAST_WORDS, default):
default = f"成年{gender},{default}"
default += ",写实角色形象,五官和发型清晰,服装符合角色身份"
return identity, default[:500]
def _cast_gender_requirements(
conversation: CreationConversation,
role_briefs: list[str],
@@ -1579,10 +1652,14 @@ def _build_single_person_reference_prompt(
if gender_requirement else ""
)
prompt = (
f"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图({role_tag}出镜角色)。"
f"只出现一位成年人物。{gender_instruction}正面或轻微三分之四角度,中近景,表情自然,"
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
f"为短视频生成一张横向角色设定图({role_tag}出镜角色),所有视角必须是同一位成年人物。"
f"{gender_instruction}严格按以下版式排布:左侧占画面约三分之二,横向并排展示这个角色的"
"全身正面、全身侧面、全身背面三视图;每一视图都从头顶到鞋底完整入画,双脚清晰,"
"不要截断头部、腿部或鞋子。右侧占画面约三分之一,上方是脸部正面清晰特写,"
"下方是脸部严格侧面清晰特写。五个视角的脸型、五官、发型、肤色、身材、"
"年龄和服装必须完全一致,不能像五个不同的人。站姿自然,服装与鞋子细节明确,"
"写实摄影,均匀柔和布光,纯色简约背景。除这位角色的多视角展示外不要其他人物;"
"不要文字、标签、边框、水印、遮脸道具或夸张滤镜。"
f"{no_product}"
)
if appearance_only:
@@ -1607,11 +1684,11 @@ def submit_generated_person_reference(
appearance_prompt: str = "",
cast_count: int | None = None,
) -> list:
"""生成人物/宠物角色定妆参考;多人物视频会一次生成多张,完成后再建模特并锁定。
"""每次只生成当前一位人物/宠物;确认后才进入下一位。
定妆图 prompt 只写角色外观,不写入商品名/卖点/用户创作简述,也不带商品参考图,
否则出图模型常会把商品画进角色照。
返回 GENERATING 消息列表(单人时长度为 1)。
返回仅含当前角色的一条 GENERATING 消息。
"""
from .services import enqueue_standalone_images
@@ -1627,22 +1704,25 @@ def submit_generated_person_reference(
context_brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700]
raw_appearance = (appearance_prompt or "").strip()[:500]
total = cast_count if cast_count is not None else (
infer_needed_cast_count(conversation, raw_appearance) - len(locked_person_references(conversation))
)
cast_have = len(locked_person_references(conversation))
total = cast_count if cast_count is not None else infer_needed_cast_count(conversation, raw_appearance)
total = max(1, min(4, int(total or 1)))
if is_pet_preset(conversation.preset):
total = 1
role_briefs = build_cast_role_briefs(total, raw_appearance)
gender_requirements = _cast_gender_requirements(conversation, role_briefs, raw_appearance)
index = min(total, cast_have + 1)
role_identity, default_prompt = cast_role_profile(conversation, index, total) if not is_pet_preset(conversation.preset) else ("宠物主角", "")
brief = _character_only_appearance_text(raw_appearance or default_prompt)
gender_requirement = _cast_gender_requirements(conversation, [brief] * total, brief)[index - 1]
memory = dict(conversation.memory or {})
memory["person_source"] = "platform_generate"
memory["person_source_pending"] = True
memory["person_prompt"] = appearance_prompt
memory["person_prompt"] = raw_appearance or default_prompt
memory["person_cast_total"] = total
memory["person_cast_pending"] = total
memory["person_cast_model_ids"] = []
memory["person_cast_pending"] = 1
memory["person_cast_index"] = index
memory["person_cast_role_identity"] = role_identity
memory.setdefault("person_cast_model_ids", [])
memory.pop("person_confirm_pending", None)
memory.pop("person_source_ready", None)
memory.pop("person_model_id", None)
@@ -1651,44 +1731,42 @@ def submit_generated_person_reference(
conversation.agent_status = CreationConversation.AgentStatus.IDLE
conversation.save(update_fields=["memory", "status", "agent_status", "updated_at"])
messages = []
for index, brief in enumerate(role_briefs, start=1):
prompt, label, model_name = _build_single_person_reference_prompt(
conversation=conversation,
appearance_only=brief,
context_brief=context_brief,
cast_index=index,
cast_total=total,
gender_requirement=gender_requirements[index - 1],
gender_requirement=gender_requirement,
)
image_ratio = "portrait" if is_pet_preset(conversation.preset) else "16:9"
tasks = enqueue_standalone_images(
team=conversation.team,
user=user,
prompt=prompt,
mode="model",
count=1,
ratio="portrait",
ratio=image_ratio,
feature="omni_create",
)
task = tasks[0]
messages.append(
append_message(
return [append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.GENERATING,
payload={
"task_id": str(task.id),
"kind": "person_reference",
"ratio": image_ratio,
"prompt": prompt,
"label": label,
"cast_index": index,
"cast_total": total,
"cast_role_identity": role_identity,
"cast_model_name": model_name,
},
task=task,
)
)
return messages
)]
def insufficient_cast_refs_message(conversation: CreationConversation, prompt: str = "") -> str:
@@ -1702,7 +1780,7 @@ def insufficient_cast_refs_message(conversation: CreationConversation, prompt: s
return ""
return (
f"这是多人物视频,需要 {needed} 张角色定妆图锁定前后形象,当前只有 {have} 张。"
"请先用「平台帮忙生成」一次生成多位角色,或从模特库/本地补齐后再出片。"
"请先逐位使用「平台帮忙生成」,或从模特库/本地补齐后再出片。"
)
@@ -2012,9 +2090,10 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
色彩与材质系统:写出主色、材质、皮肤/产品/环境的可见质感。
打光规则:光源方向、软硬、色温、人物和产品分别如何受光。
剪辑节奏:列出时间段与 Hook → 证据/体验 → 转化收束的推进逻辑。
剧情反转带货例外:上述广告节奏让位于完整故事的起因→行动→受阻→升级→反转→结果;商品证据嵌入剧情因果,结局之后才自然收束,不要把 60 秒写成某一场戏的片段。
声音方向:人声身份、语气、语速、环境声/拟音、背景音乐的进入和收束;人声原文必须分配到对应分镜。
场景:逐一写清可见地点、前中后景、环境道具与景深。
主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。
主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。多人物时先分行写「角色1:身份、性别、成年年龄段、发型与服装」「角色2:…」,每位角色单独一行,便于逐位生成定妆图;角色外观不得与剧情身份矛盾。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。
各个分镜的具体内容:按时间顺序逐段展开。
分镜不能省略细节:每一段都必须严格用下面四行写完,不得只写一句画面描述:
@@ -3087,6 +3166,9 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"""给模型看的工具清单。图片会话不暴露 generate_video,反之亦然 ——
会话 mode 是定死的(契约 §0),把不该用的工具摆出来只会诱导模型走错路。
allow_plan=False 时隐藏 write_strategy / write_plan / generate_image,闲聊用不出来。"""
plot_full_length = video_duration(context.conversation.params or {}) >= 45
plot_beats = "开端、冲突、升级、反转、结局" if plot_full_length else "开端、冲突、反转、结局"
plot_stage_count = 5 if plot_full_length else 4
tools = [
{
"type": "function",
@@ -3166,7 +3248,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"function": {
"name": "present_story_directions",
"description": (
"剧情反转带货预设在用户选定故事深度后,先调用此工具展示 3 个可点击的剧情方向。"
"剧情反转带货预设默认按 60 秒完整短剧展开,先调用此工具展示 3 个可点击的剧情方向。"
"每条都必须有不同的冲突、商品承担的实际作用、反转和情绪;不能只说‘我准备了三个方向’。"
"用户点击其一后才可 write_strategy;本工具调用后必须停下来等待选择。"
),
@@ -3236,8 +3318,12 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、"
"声音/灯光/场景/参考素材锁定、可执行镜头和一致性收束,不要只写大纲。"
)
+
"第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
+ (
f"剧情反转带货须先写【完整故事线】的{plot_beats},"
f"再按至少{plot_stage_count}段连续时间轴写完整的一集;结尾解决开头的问题,不能只交中间一场戏。"
if is_plot_twist_conversation(context.conversation) else ""
)
+ "第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
"先完成内部 write_strategy,再调它。修改架构时必须沿用上一版,只改用户指出的部分,未受影响的时间段原样保留。"
),
"parameters": {
@@ -3283,6 +3369,11 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"60 秒以内每一镜包含拍法/画面内容/主体或产品露出/声音;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。"
"已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字。"
"人物必须明确为成年人且构图得体;只写正向可拍内容,不列风险词或禁用词。"
+ (
f"剧情预设另须先逐行写【完整故事线】的{plot_beats},"
"每段有具体因果;分镜连续覆盖全片,结尾完成角色目标或关系的结果。"
if is_plot_twist_conversation(context.conversation) else ""
)
),
},
},
@@ -3305,6 +3396,10 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
else
"video_prompt 必须是系统规定的制作级完整文件,不能只把旧 Prompt 缩写成几行分镜。"
)
+ (
"剧情预设必须保留开端到结局的完整故事线和全片分镜,不可只写故事中的一个片段。"
if is_plot_twist_conversation(context.conversation) else ""
)
),
"parameters": {
"type": "object",
@@ -4194,6 +4289,23 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
]
if context.is_video:
lines.extend(["", _OMNI_VIDEO_PROMPT_RULES.strip()])
if is_plot_twist_conversation(conversation):
plot_full_length = video_duration(conversation.params or {}) >= 45
plot_beat_lines = (
"「开端:」「冲突:」「升级:」「反转:」「结局:」"
if plot_full_length else "「开端:」「冲突:」「反转:」「结局:」"
)
plot_stage_count = 5 if plot_full_length else 4
lines.append(
"【短剧完整故事线·优先于通用广告节奏】默认 60 秒是一集独立完整短剧;较短时长也必须讲完故事。"
"write_plan 的 video_prompt 在技术规则和分镜前必须有【完整故事线】,"
f"按顺序逐行写{plot_beat_lines},"
"每行写具体人物、目标、行动与可见结果,不是几个空标题。"
"从第一秒的起因到最后一秒的结局覆盖全片;前 3 秒闪前后也要回到起因。"
f"方案时间轴至少{plot_stage_count}段连续覆盖全时长,逐镜从故事线展开,镜头之间明确‘因为前一动作,所以发生下一动作’。"
"结尾先完成冲突与人物关系的回收,再自然表达商品价值,不留未完待续。"
"write_prompt 修改时仍保留完整故事线和全时长分镜,不能只截取中间一场戏。"
)
lines.extend([
"",
"【视频创作工作原则】",
@@ -4311,7 +4423,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
if is_plot_twist_conversation(conversation):
depth = active_plot_twist_story_depth(conversation)
if not depth:
lines.append("剧情反转带货尚未选择故事深度:必须先调用 ask_user 让用户选 15 秒、30 秒、60 秒或智能推荐;此时禁止给剧情方向、策略或方案。")
lines.append("剧情反转带货默认 60 秒完整短剧;不要追问开场时长,先按 60 秒核对商品,再展示剧情方向。")
else:
lines.append(plot_twist_story_contract(depth))
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
@@ -4964,18 +5076,10 @@ def iter_creation_agent_events(
yield {"type": "done"}
return
# 剧情带货的第一个必经步骤永远是选时长;之后才核对商品与剧情方向。
# 剧情带货新会话默认 60 秒,不再让用户先选 15/30/60 秒;
# 历史会话明确选过的时长仍保留,避免重进后改写已确认的结构。
if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation):
explicit_depth = plot_twist_story_depth(text)
if explicit_depth is not None and explicit_depth["value"] != "smart":
set_plot_twist_story_depth(conversation, explicit_depth["value"])
else:
question = append_plot_twist_story_depth_question(conversation)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
set_plot_twist_story_depth(conversation, "60s")
# 0. 本地上传商品图优先确认品牌与具体品名
if product_info_needs_confirmation(conversation, text):
@@ -5761,6 +5865,25 @@ def _dispatch_tool(
fields = _coerce_fields(args.get("fields"))
if not fields:
return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False
# 剧情方向不能退化为普通单选:ask_user 的 options 只有标题,
# 用户看不到冲突、商品作用与反转。统一改用有完整内容的方向卡。
if (
context.is_video
and is_plot_twist_conversation(context.conversation)
and active_plot_twist_story_depth(context.conversation)
and not plot_twist_selected_direction(context.conversation)
and any(
"story_direction" in str(field.get("key") or "").lower()
or "plot_direction" in str(field.get("key") or "").lower()
or re.search(r"(?:剧情|故事).{0,6}方向", str(field.get("label") or ""))
for field in fields
)
):
return _dispatch_tool(
context,
"present_story_directions",
{"directions": _plot_twist_direction_fallback(context.conversation)},
)
# 临时隐藏 >60 秒长视频:模型自拟的时长选项也裁掉
for field in fields:
if not isinstance(field, dict):
@@ -5896,6 +6019,15 @@ def _dispatch_tool(
video_prompt = str(args.get("video_prompt") or "").strip()
if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
card = _coerce_plan_card_args(args if isinstance(args, dict) else {})
if is_plot_twist_conversation(context.conversation):
issue = plot_twist_storyline_issue(
video_prompt,
timeline=card["timeline"],
duration=video_duration(context.conversation.params or {}),
)
if issue:
return {"payload": {"error": f"短剧不是故事中的一小段。{issue}请重写完整方案和 Prompt。"}}, False
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
@@ -5932,7 +6064,6 @@ def _dispatch_tool(
"error": "剧情反转带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。"
}
}, False
card = _coerce_plan_card_args(args if isinstance(args, dict) else {})
if not card["usp"] or not card["points"]:
return {
"payload": {
@@ -6031,6 +6162,13 @@ def _dispatch_tool(
video_prompt = get_pending_video_prompt(context.conversation)
if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
if is_plot_twist_conversation(context.conversation):
issue = plot_twist_storyline_issue(
video_prompt,
duration=video_duration(context.conversation.params or {}),
)
if issue:
return {"payload": {"error": f"短剧出片指令需要完整故事。{issue}请保留整条故事线后重写。"}}, False
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
+23 -15
View File
@@ -43,8 +43,11 @@ VIDEO_PRESETS: dict[str, str] = {
"剧情反转带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物,"
"并在最终 Prompt、镜头、动作、对白和反转结果中反复可见地承担这个作用。"
"不得在故事结束后突然停下来介绍商品,也不能只在最后两秒放商品图。"
"必须先确定故事深度,再按所选 15 秒、30 秒或 60 秒的结构写人物关系、冲突铺垫、商品介入时机、反转和情绪落点;"
"禁止把同一条 15 秒故事机械加长或缩短。"
"开场默认按 60 秒完整短剧结构写人物关系、持续冲突、商品介入时机、反转和情绪落点,不追问 15/30 秒故事深度;"
"方案和视频 Prompt 都必须从故事的起因讲到结果:人物有具体目标,行动遭遇阻碍,失败或选择使局面升级,"
"商品通过真实使用改变局势,反转后要交代人物关系或目标的最终结果。"
"前 3 秒可以闪前但必须回到起因,最后不能停在中途冲突、悬念或一句带货口号上;"
"若用户后续明确修改视频时长,再按新时长重写结构,不能机械拉长或缩短原方案。"
),
"商品拟人广告": (
"把商品当成有性格的角色,但默认采用无脸拟人:商品本体保持真实完整,不在瓶身、包装或机身上新增卡通五官。"
@@ -100,7 +103,7 @@ VIDEO_PRESETS: dict[str, str] = {
PLOT_TWIST_PRESET = "剧情反转带货"
# 值同时是会话记忆中的稳定标识;label 则是用户在对话里看到的短说明。
# 保留旧会话 15/30 秒的稳定标识以便回放;新会话不再展示故事深度选择卡。
PLOT_TWIST_STORY_DEPTH_OPTIONS = (
{
"value": "15s",
@@ -157,26 +160,33 @@ def plot_twist_story_contract(value: str) -> str:
depth = plot_twist_story_depth(value)
if depth is None:
return ""
if depth["value"] == "smart":
depth = _PLOT_TWIST_DEPTH_BY_VALUE["60s"]
if depth["value"] == "15s":
return (
"【剧情反转带货·15秒快节奏反转·强制执行】只保留一条主冲突,主角 1 人、最多 1 名辅助人物。"
"【剧情反转带货·15秒快节奏反转·强制执行】虽短也要有开端、冲突、反转和明确结局,不能只截取一场戏;"
"只保留一条主冲突,主角 1 人、最多 1 名辅助人物。"
"0-3 秒强冲突或意外,3-6 秒问题升级,6-11 秒商品以正常使用动作介入并证明一个核心卖点,"
"11-15 秒完成结果反转与自然行动引导。商品必须在中段前出现;对白短、直接、有记忆点;"
"禁止复杂背景、支线、重复卖点和与商品无关的镜头。"
)
if depth["value"] == "30s":
return (
"【剧情反转带货·30秒轻剧情带货·强制执行】必须交代人物处境,可设置两人互动。"
"【剧情反转带货·30秒轻剧情带货·强制执行】写完人物目标从开端、冲突、反转到结局的因果线,"
"不可只写故事中段;必须交代人物处境,可设置两人互动。"
"0-4 秒抛出结果预告或冲突钩子,4-10 秒建立处境,10-17 秒让矛盾升级或一次错误尝试,"
"17-24 秒商品在转折点以完整正常使用过程介入,24-28 秒回收反转与人物反应,28-30 秒自然收束。"
"商品承担改变局面的实际作用;反转既服务剧情也证明卖点,禁止为了凑时长重复同一卖点。"
)
if depth["value"] == "60s":
return (
"【剧情反转带货·60秒完整短剧带货·强制执行】必须有人物关系、持续发展的矛盾、一次铺垫和一次回收。"
"0-5 秒高强度钩子,5-15 秒交代关系和处境,15-28 秒矛盾逐步升级,28-38 秒主角面临选择或第一次失败,"
"38-48 秒商品作为关键线索、工具、证据或关系转折点推进剧情,48-56 秒完成主要反转和情绪释放,"
"56-60 秒回扣商品价值并自然转化。商品可前置为伏笔但必须在后半段真正改变结局;"
"【剧情反转带货·60秒完整短剧带货·强制执行】这是一集有开端和结局的独立短剧,不是一部长剧中截取的一段。"
"先写【完整故事线】,依次写清开端、冲突、升级、反转、结局;每一段都有前因后果,人物目标和关系最终有结果。"
"0-5 秒可以用结果闪前作钩子,但随后必须回到故事起因;5-15 秒交代关系、目标与处境;"
"15-28 秒发生具体阻碍并升级;28-38 秒主角尝试、失败或作出选择;"
"38-48 秒商品作为提前铺垫过的线索、工具或证据,通过正常使用真正改变局势;"
"48-56 秒完成反转并展示可见后果;56-60 秒回收伏笔和人物关系,给故事结局,再自然带出商品价值。"
"结尾不是故事刚要开始、冲突悬而未决或单独口号式 CTA;商品不能只在结尾突然出现。"
"禁止用重复对白、无意义空镜或硬插卖点填满时长。"
)
if depth["value"] == "180s":
@@ -191,10 +201,7 @@ def plot_twist_story_contract(value: str) -> str:
"每章都要推进新的因果关系;同一角色、服装、商品/SKU、场景空间和光线跨六个 30 秒分段连续,"
"禁止重复对白、重复卖点、空镜凑时长或在章节边界换人换商品。"
)
return (
"【剧情反转带货·智能推荐】先根据商品卖点、已有素材和剧情空间推荐 15、30 或 60 秒之一并说明理由;"
"随后必须让用户选择实际时长,未确认前不得写剧情方向、策略、方案或出片指令。"
)
return ""
def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) -> str:
@@ -270,7 +277,7 @@ def apply_plot_twist_direction_contract(prompt: str, *, title: str = "", conflic
VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
"痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。",
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
"剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐;暂不开放更长时长),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
"剧情反转带货": "默认按 60 秒完整短剧带货创作,不在开头追问或展示 15/30 秒故事深度选择;核对商品信息后给三个完整剧情方向。商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
"达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
@@ -298,7 +305,8 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
"用近景展示材质、动作或结果。商品全程保持正常完整;不以破损、漏液、渗水、超载或超出用途的测试制造戏剧性。"
),
"剧情反转带货": (
"【预设执行层·剧情反转带货】人物关系、冲突、情绪变化和反转必须在每个关键镜头连续推进。"
"【预设执行层·剧情反转带货】按同一条完整故事线连续推进开端、冲突、升级、反转和结局;"
"人物关系、目标与情绪在每个关键镜头都有因果变化,结尾回收开头问题而非停在故事中段。"
"商品不是摆拍道具:必须充当解决问题的工具、解除误会的证据、关系变化的礼物或回收伏笔的关键物;"
"商品出现、使用和结果必须直接改变剧情走向,禁止剧情结束后再硬插卖点。"
),
+11
View File
@@ -1,5 +1,6 @@
from rest_framework import serializers
from .creation_presets import PLOT_TWIST_PRESET
from .models import (
AITask,
CreationConversation,
@@ -246,6 +247,16 @@ class CreationConversationSerializer(serializers.ModelSerializer):
progress["history"] = list(reversed(unique_reversed))[-12:]
return progress
def create(self, validated_data):
# 剧情反转带货的新会话直接以完整短剧的 60 秒开场,不再弹故事深度选择。
# 旧客户端仍传「智能时长」时也要在服务端落成确定参数。
if validated_data.get("mode") == CreationConversation.Mode.VIDEO and validated_data.get("preset") == PLOT_TWIST_PRESET:
params = dict(validated_data.get("params") or {})
if not str(params.get("duration") or "").strip() or str(params.get("duration")).strip() == "智能时长":
params["duration"] = "60 秒"
validated_data["params"] = params
return super().create(validated_data)
def update(self, instance, validated_data):
# mode 定死:允许传但忽略,避免前端误改后顶栏参数与已生成内容对不上
validated_data.pop("mode", None)
+9 -1
View File
@@ -3980,7 +3980,15 @@ def run_standalone_image_task(*, task_id: str) -> None:
vsize = "2304x1728" if payload.get("reference_product") else _ratio_to_volcano_size(output_ratio)
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size=vsize)
else:
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=gen_prompt)
generate_kwargs = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt}
if mode == "model" and payload.get("feature") == "omni_create" and output_ratio == "16:9":
# 全能创作角色设定图没有参考图,旧分支漏传 size,会退回供应商默认竖幅。
generate_kwargs["size"] = (
_ratio_to_volcano_size(output_ratio)
if model_config.provider.name in OFFICIAL_DIRECT_PROVIDERS
else _ratio_to_image_size(output_ratio)
)
response = provider.image_generation(**generate_kwargs)
if not use_model_routing:
media = provider.extract_first_media_url(response)
with transaction.atomic():
+212 -39
View File
@@ -35,7 +35,9 @@ from .creation_agent import (
apply_product_reference_guard,
active_plot_twist_story_depth,
plot_twist_selected_direction,
plot_twist_storyline_issue,
append_multi_character_relation_gate,
append_person_source_gate,
append_step_confirm,
build_system_prompt,
build_messages,
@@ -71,6 +73,7 @@ from .creation_agent import (
text_already_guides,
tool_schemas,
_video_submit_params,
_dispatch_tool,
video_duration,
video_model_name,
wanted_asset_pick,
@@ -638,7 +641,7 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.AWAITING_USER)
model_ref = next(ref for ref in self.conversation.pinned_refs if ref.get("type") == "model")
self.assertTrue(AssetModel.objects.filter(id=model_ref["id"], portrait_asset=asset).exists())
self.assertTrue(self.conversation.memory["person_source_ready"])
self.assertTrue(self.conversation.memory["person_source_ready"], self.conversation.memory)
self.assertTrue(self.conversation.memory["person_confirm_pending"])
self.assertTrue(
self.conversation.messages.filter(text__contains="是否使用这个角色").exists()
@@ -665,8 +668,12 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
self.assertEqual(message.payload["kind"], "person_reference")
self.assertEqual(enqueue.call_args.kwargs["mode"], "model")
self.assertEqual(enqueue.call_args.kwargs["count"], 1)
self.assertEqual(enqueue.call_args.kwargs["ratio"], "16:9")
self.assertEqual(enqueue.call_args.kwargs["feature"], "omni_create")
self.assertEqual(message.payload["ratio"], "16:9")
self.assertIn("利落短发", enqueue.call_args.kwargs["prompt"])
for layout_part in ("全身正面", "全身侧面", "全身背面", "脸部正面", "脸部严格侧面", "纯色简约背景"):
self.assertIn(layout_part, enqueue.call_args.kwargs["prompt"])
self.assertIn("禁止出现任何商品", enqueue.call_args.kwargs["prompt"])
self.assertNotIn("参考用户需求", enqueue.call_args.kwargs["prompt"])
self.conversation.refresh_from_db()
@@ -708,8 +715,8 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
self.assertIsNone(enqueue.call_args.kwargs.get("product_id"))
self.assertFalse(enqueue.call_args.kwargs.get("reference_product"))
def test_multi_person_brief_enqueues_multiple_character_refs(self):
"""多人物开场描述时,平台生成应一次提交多张定妆图。"""
def test_multi_person_brief_enqueues_one_character_at_a_time(self):
"""先提交母亲,确认后才进入女儿,不能在后台同时排两张图。"""
from .creation import append_message
append_message(
@@ -718,6 +725,13 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
kind=CreationMessage.Kind.TEXT,
text="做一条两位空乘的剧情带货,母女互动,60秒",
)
self.conversation.memory = {
"pending_video_prompt": "角色1:母亲,45岁长发,稳重通勤装。\n角色2:女儿,25岁短发,休闲装。"
}
self.conversation.save(update_fields=["memory", "updated_at"])
gate = append_person_source_gate(self.conversation)
self.assertIn("母亲", gate.text)
self.assertIn("母亲", gate.payload["default_person_prompt"])
tasks = [
AITask.objects.create(
team=self.team,
@@ -728,19 +742,47 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
)
for index in (1, 2)
]
with patch("apps.ai.services.enqueue_standalone_images", side_effect=[[tasks[0]], [tasks[1]]]) as enqueue:
with patch("apps.ai.services.enqueue_standalone_images", return_value=[tasks[0]]) as enqueue:
messages = submit_generated_person_reference(
conversation=self.conversation,
user=self.user,
appearance_prompt="角色1:25岁短发空乘;角色2:45岁长发妈妈",
)
self.assertEqual(len(messages), 2)
self.assertEqual(enqueue.call_count, 2)
self.assertEqual(len(messages), 1)
self.assertEqual(enqueue.call_count, 1)
self.assertEqual(messages[0].payload.get("cast_total"), 2)
self.assertEqual(messages[1].payload.get("cast_index"), 2)
self.assertEqual(messages[0].payload.get("cast_index"), 1)
self.assertIn("母亲", enqueue.call_args.kwargs["prompt"])
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.memory.get("person_cast_total"), 2)
self.assertEqual(self.conversation.memory.get("person_cast_pending"), 2)
self.assertEqual(self.conversation.memory.get("person_cast_pending"), 1)
asset = Asset.objects.create(
team=self.team, created_by=self.user, name="母亲定妆",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED,
category=Asset.Category.MODEL_PORTRAIT, origin_task=tasks[0],
)
finish_generating_message(messages[0], assets=[{"id": str(asset.id), "url": "https://cdn.example/mother.jpg"}], meta={})
self.conversation.refresh_from_db()
self.assertFalse(self.conversation.memory["person_source_ready"])
self.assertTrue(self.conversation.memory["person_confirm_pending"])
second_gate = append_person_source_gate(self.conversation)
self.assertIn("女儿", second_gate.text)
self.assertIn("女儿", second_gate.payload["default_person_prompt"])
with patch("apps.ai.services.enqueue_standalone_images", return_value=[tasks[1]]) as second_enqueue:
second = submit_generated_person_reference(conversation=self.conversation, user=self.user)
self.assertEqual(len(second), 1)
self.assertEqual(second_enqueue.call_count, 1)
self.assertEqual(second[0].payload["cast_index"], 2)
self.assertIn("女儿", second_enqueue.call_args.kwargs["prompt"])
second_asset = Asset.objects.create(
team=self.team, created_by=self.user, name="女儿定妆",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED,
category=Asset.Category.MODEL_PORTRAIT, origin_task=tasks[1],
)
finish_generating_message(second[0], assets=[{"id": str(second_asset.id), "url": "https://cdn.example/daughter.jpg"}], meta={})
self.conversation.refresh_from_db()
self.assertTrue(self.conversation.memory["person_source_ready"], self.conversation.memory)
self.assertEqual(len([ref for ref in self.conversation.pinned_refs if ref["type"] == "model"]), 2)
def test_video_prompt_two_adult_women_applies_to_both_character_images(self):
"""用户未额外填写外观时,视频 Prompt 的两位女性约束不能在生图阶段丢失。"""
@@ -758,14 +800,14 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
)
for index in (1, 2)
]
with patch("apps.ai.services.enqueue_standalone_images", side_effect=[[tasks[0]], [tasks[1]]]) as enqueue:
with patch("apps.ai.services.enqueue_standalone_images", return_value=[tasks[0]]) as enqueue:
messages = submit_generated_person_reference(
conversation=self.conversation,
user=self.user,
)
self.assertEqual(len(messages), 2)
self.assertEqual(enqueue.call_count, 2)
self.assertEqual(len(messages), 1)
self.assertEqual(enqueue.call_count, 1)
for call in enqueue.call_args_list:
prompt = call.kwargs["prompt"]
self.assertIn("必须是成年女性", prompt)
@@ -1723,7 +1765,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.params["duration"], "15 秒")
def test_plot_twist_requires_story_depth_before_product(self):
def test_plot_twist_defaults_to_60_seconds_before_product(self):
self.conversation.preset = "剧情反转带货"
self.conversation.params = {**self.conversation.params, "duration": "智能时长"}
self.conversation.memory = {}
@@ -1738,21 +1780,17 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
model_config=self.model,
))
question = next(
event["message"] for event in events
if event.get("type") == "message"
and event["message"]["kind"] == "elicit"
)
self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth")
self.assertEqual(
[option["value"] for option in question["payload"]["fields"][0]["options"]],
["15s", "30s", "60s"],
)
self.assertFalse(any(
event.get("type") == "message"
and event["message"].get("payload", {}).get("interaction") == "plot_twist_story_depth"
for event in events
))
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.agent_status, "awaiting_user")
self.assertEqual(self.conversation.params["duration"], "60 秒")
self.assertEqual(active_plot_twist_story_depth(self.conversation), "60s")
self.assertEqual(fake.calls, [])
def test_plot_twist_requires_story_depth_after_product_is_ready(self):
def test_plot_twist_defaults_to_60_seconds_after_product_is_ready(self):
self._pin_product("洗面奶")
self.conversation.preset = "剧情反转带货"
self.conversation.params = {**self.conversation.params, "duration": "智能时长"}
@@ -1768,17 +1806,107 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
model_config=self.model,
))
question = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth")
self.assertEqual(
[option["value"] for option in question["payload"]["fields"][0]["options"]],
["15s", "30s", "60s"],
)
self.assertFalse(any(
event.get("type") == "message"
and event["message"].get("payload", {}).get("interaction") == "plot_twist_story_depth"
for event in events
))
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.params["duration"], "60 秒")
self.assertEqual(active_plot_twist_story_depth(self.conversation), "60s")
self.assertEqual(fake.calls, [])
def test_plot_twist_creation_normalizes_legacy_smart_duration_to_60_seconds(self):
from apps.ai.serializers import CreationConversationSerializer
serializer = CreationConversationSerializer(data={
"title": "剧情短剧",
"mode": "video",
"preset": "剧情反转带货",
"params": {"duration": "智能时长", "ratio": "9:16"},
})
self.assertTrue(serializer.is_valid(), serializer.errors)
conversation = serializer.save(team=self.team, created_by=self.user)
self.assertEqual(conversation.params["duration"], "60 秒")
self.assertEqual(conversation.params["ratio"], "9:16")
def test_plot_twist_legacy_smart_choice_uses_60_second_story_contract(self):
from apps.ai.creation_presets import plot_twist_story_contract
self.conversation.preset = "剧情反转带货"
self.conversation.save(update_fields=["preset", "updated_at"])
depth = set_plot_twist_story_depth(self.conversation, "smart")
self.assertEqual(depth["value"], "60s")
self.assertEqual(self.conversation.params["duration"], "60 秒")
self.assertIn("60秒完整短剧带货", plot_twist_story_contract("smart"))
def test_plot_twist_prompt_requires_whole_story_not_middle_scene(self):
self.conversation.preset = "剧情反转带货"
self.conversation.params = {**self.conversation.params, "duration": "60 秒"}
self.conversation.memory = {"plot_twist_story_direction": "误会翻盘", "plot_twist_story_depth": "60s"}
self.conversation.save(update_fields=["preset", "params", "memory", "updated_at"])
context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model)
fragment = "38-48秒商品出现,主角发现误会;48-60秒两人相视一笑,结束。"
timeline = [
{"start": start, "end": end, "stage": stage, "visual": "角色继续行动", "action_dialogue": "角色自然对话", "product": "商品推动剧情", "purpose": stage}
for start, end, stage in (
(0, 12, "开端"), (12, 24, "冲突"), (24, 36, "升级"),
(36, 48, "反转"), (48, 60, "结局"),
)
]
rejected, stop = _dispatch_tool(context, "write_plan", self._plan_args(
timeline=timeline, video_prompt=fragment,
))
self.assertFalse(stop)
self.assertIn("完整故事线", rejected["payload"]["error"])
self.assertFalse(self.conversation.messages.filter(kind=CreationMessage.Kind.PLAN).exists())
full_story = """【完整故事线】
开端:母亲准备送女儿一份日常礼物,女儿却误以为她又忘记了自己的喜好。
冲突:女儿拒绝收下礼盒,母亲解释不清,两人原本约好的见面因此变得尴尬。
升级:母亲先拿出旧礼物仍没说服女儿,女儿决定提前离开,两人关系继续紧张。
反转:母亲打开事先准备的商品并示范真实用法,女儿看到细节才明白母亲一直记得自己的需要。
结局:女儿主动留下并接受礼物,母女重新坐下聊天,开头的误会得到回应,商品价值自然落定。
0-12秒建立人物关系;12-24秒误会形成;24-36秒尝试失败;36-48秒商品介入;48-60秒关系修复。"""
self.assertEqual(plot_twist_storyline_issue(full_story, timeline=timeline), "")
self.assertIn(
"从 0 秒连续写到",
plot_twist_storyline_issue(full_story.rsplit("0-12秒", 1)[0] + "38-48秒商品介入;48-60秒关系修复。"),
)
self.assertIn("未完待续", plot_twist_storyline_issue(full_story + "\n未完待续"))
self.assertIn("完整故事线", plot_twist_storyline_issue(fragment, duration=30))
short_story = """【完整故事线】
开端:男主赶车时发现自己忘记给家里的猫准备当天的食物。
冲突:他联系不上邻居帮忙,担心猫会饿着,急忙寻找解决办法。
反转:手机提醒显示喂食器已定时出粮,男主通过画面确认猫正在进食。
结局:男主放下心继续行程,到家后安抚猫咪,开头的担忧得到解决。
0-5秒发现忘记喂猫;5-12秒尝试求助;12-22秒设备定时出粮;22-30秒担忧解除。"""
self.assertEqual(plot_twist_storyline_issue(short_story, duration=30), "")
self.assertIn(
"时间空档",
plot_twist_storyline_issue(
full_story.rsplit("0-12秒", 1)[0]
+ "0-5秒交代人物;5-10秒建立误会;25-35秒误会升级;35-48秒商品介入;48-60秒关系修复。"
),
)
accepted, stop = _dispatch_tool(context, "write_plan", self._plan_args(
timeline=timeline, video_prompt=full_story,
))
self.assertTrue(stop)
self.assertEqual(accepted["payload"]["awaiting_step"], "plan")
self.conversation.refresh_from_db()
self.assertIn("结局:女儿主动留下", self.conversation.memory["pending_video_prompt"])
rejected_prompt, stop = _dispatch_tool(context, "write_prompt", {"video_prompt": fragment})
self.assertFalse(stop)
self.assertIn("完整故事线", rejected_prompt["payload"]["error"])
system = build_system_prompt(context)
self.assertIn("一集独立完整短剧", system)
self.assertIn("【完整故事线】", system)
def test_product_information_is_reviewed_as_status_checklist(self):
self._pin_product("舒缓面霜")
self.conversation.preset = "达人口播种草"
@@ -1812,6 +1940,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(depth["duration"], "30 秒")
self.conversation.refresh_from_db()
self.assertEqual(active_plot_twist_story_depth(self.conversation), "30s")
self._pin_person()
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
@@ -1859,6 +1988,41 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(len(directions), 3)
self.assertTrue(all(item.get("conflict") and item.get("product_role") and item.get("reversal") for item in directions))
def test_plot_twist_title_only_ask_user_becomes_explained_direction_cards(self):
self._pin_product("补水面霜")
self._pin_person()
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.preset = "剧情反转带货"
self.conversation.params = {**self.conversation.params, "duration": "30 秒"}
self.conversation.save(update_fields=["mode", "preset", "params", "updated_at"])
fake = FakeProvider([_tool_chunks("ask_user", {"fields": [{
"key": "story_direction",
"label": "选一个30秒轻剧情方向",
"type": "single",
"options": [
{"value": "a", "label": "行李箱里的误会"},
{"value": "b", "label": "梳妆台上的惊喜"},
{"value": "c", "label": "临时借用的反转"},
],
}]})])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="按商品做一条轻剧情带货视频",
model_config=self.model,
))
cards = [event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"]
self.assertEqual(len(cards), 1)
self.assertEqual(cards[0]["payload"]["interaction"], "plot_twist_directions")
directions = cards[0]["payload"]["directions"]
self.assertEqual(len(directions), 3)
self.assertTrue(all(item["conflict"] and item["product_role"] and item["reversal"] for item in directions))
self.assertIn("补水面霜", directions[0]["product_role"])
def test_plot_twist_direction_is_locked_into_plan_prompt(self):
"""已选剧情方向必须写进方案 video_prompt,不能漂成另一套默认带货故事。"""
from apps.ai.creation_agent import get_pending_video_prompt
@@ -1874,6 +2038,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
"selling_point_ready": True,
"selling_point_mode": "manual",
"selling_point": "定时定量",
"product_brief_reviewed": True,
"stage": "strategy",
"strategy_confirmed": True,
}
@@ -1902,12 +2067,20 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertIn("【剧情反转方向·强制执行·最高优先级】", system)
self.assertIn("邻居误以为", system)
drifted = (
"0-5秒男主高铁站发现忘了喂猫;5-12秒想象猫挨饿;"
"12-22秒回家发现喂食器已自动出粮;22-30秒抚摸猫咪收束。"
drifted = """【完整故事线】
开端:男主赶车时发现自己忘记给家里的猫准备当天的食物。
冲突:他联系不上邻居帮忙,担心猫会饿着,急忙寻找解决办法。
反转:手机提醒显示喂食器已定时出粮,男主通过画面确认猫正在进食。
结局:男主放下心继续行程,到家后安抚猫咪,开头的担忧得到解决。
0-5秒发现忘记喂猫;5-12秒尝试求助;12-22秒设备定时出粮;22-30秒担忧解除。"""
timeline = [
{"start": start, "end": end, "stage": stage, "visual": "角色行动", "action_dialogue": "角色对话", "product": "喂食器出粮", "purpose": stage}
for start, end, stage in (
(0, 5, "开端"), (5, 12, "冲突"), (12, 22, "反转"), (22, 30, "结局"),
)
]
fake = FakeProvider([
_tool_chunks("write_plan", self._plan_args(video_prompt=drifted)),
_tool_chunks("write_plan", self._plan_args(timeline=timeline, video_prompt=drifted)),
_text_chunks("不该继续"),
])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
@@ -1920,7 +2093,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertTrue(any(
event.get("type") == "message" and event["message"]["kind"] == "plan"
for event in events
))
), events)
# 出片指令不再挂在方案卡上,改为存成待确认的 pending prompt。
self.conversation.refresh_from_db()
prompt = get_pending_video_prompt(self.conversation)
@@ -265,6 +265,31 @@ class StandaloneSingleImageRoutingTests(TestCase):
self.assertEqual(asset.category, Asset.Category.MODEL_PORTRAIT)
submit_review.assert_called_once_with(asset)
def test_omni_character_sheet_requests_landscape_canvas(self):
primary = self.model(self.provider("character-sheet-primary", 100), "character-sheet-model")
task = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt="同一位成年角色:左侧全身三视图,右侧脸部正面和侧面特写",
mode="model",
count=1,
ratio="16:9",
image_model=f"{primary.provider.name}:{primary.name}",
feature="omni_create",
dispatch=False,
)[0]
with patch("apps.assets.review.submit_asset_for_review"):
with self.captureOnCommitCallbacks(execute=True):
run_standalone_image_task(task_id=str(task.id))
self.assertEqual(task.request_payload["ratio"], "16:9")
self.provider_mocks[primary.id].image_generation.assert_called_once()
self.assertEqual(
self.provider_mocks[primary.id].image_generation.call_args.kwargs["size"],
"1536x864",
)
def test_primary_retry_then_dynamic_candidate_success_has_one_charge(self):
primary = self.model(self.provider("single-fallback-primary", 100), "primary", base_cost="0.25")
candidate = self.model(self.provider("volcano", 10), "candidate", outbound=False, base_cost="0.75")
+9 -15
View File
@@ -237,16 +237,12 @@ def _open_product_picker(
def _plot_twist_depth_continuation(depth: dict) -> str:
"""让故事深度卡的回答直接进入对应的创作分支。"""
if depth.get("value") == "smart":
return (
"用户选择智能推荐。先根据商品卖点、已有素材和剧情空间推荐 15、30 或 60 秒其中一个,"
"说明一句推荐理由,再调用 ask_user 让用户最终选择实际时长;未确认实际时长前不要给剧情方向、策略或方案。"
)
"""兼容旧故事深度卡的回答;新会话默认直接使用 60 秒。"""
return (
f"用户已选择:{depth['label']}。立刻按这个故事深度给出 3 个明显不同的剧情方向,"
"每个方向必须写人物关系、开场冲突、商品如何进入剧情、商品承担的作用、最终反转、情绪和偏故事/偏转化;"
"然后调用 ask_user 让用户点击选择或输入自己的想法。不得按默认 15 秒偷换结构。"
"调用 present_story_directions 展示完整方向卡,让用户点击选择或输入自己的想法;"
"不要用 ask_user 只列三个标题。不得按默认 15 秒偷换结构。"
)
@@ -2482,12 +2478,8 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
appearance_prompt = f"{prev_prompt};按用户最新要求调整:{clean_text}"
else:
appearance_prompt = clean_text
# 卸掉上一批平台定妆锁定,避免新旧角色叠在 pinned_refs 里
old_model_ids = {
str(item).strip()
for item in (memory_now.get("person_cast_model_ids") or [])
if str(item).strip()
}
# 只替换正在确认的这一位,已确认的前面角色保持锁定。
old_model_ids = set()
legacy_id = str(memory_now.get("person_model_id") or "").strip()
if legacy_id:
old_model_ids.add(legacy_id)
@@ -2503,9 +2495,11 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
memory_now["person_confirm_pending"] = False
memory_now.pop("person_source_ready", None)
memory_now.pop("person_model_id", None)
memory_now.pop("person_cast_model_ids", None)
memory_now["person_cast_model_ids"] = [
item for item in (memory_now.get("person_cast_model_ids") or [])
if str(item) not in old_model_ids
]
memory_now.pop("person_cast_pending", None)
memory_now.pop("person_cast_total", None)
conversation.memory = memory_now
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
try:
@@ -1,14 +1,16 @@
// 自由创作·底部输入条:参考素材上传区(universal 混排 / keyframe 首尾帧两格)+ @mention 提示词 + 工具栏。
// 拖拽/点击上传;素材选中即传后端(blob 预览 → 服务端 URL 替换),blob 状态禁止提交。
import { useRef, useState, type RefObject } from "react";
import { ImagePlus, Images, UserRoundPlus } from "lucide-react";
import { Film, ImagePlus, Images, Music2 } from "lucide-react";
import type { ModelConfig } from "../../types";
import { MODE_LABELS, type BillingRates, type FreeMode, type LocalRef } from "./constants";
import { PromptInput, type PromptInputHandle } from "./prompt-input";
import { FreeToolbar } from "./toolbar";
const ACCEPT_UNIVERSAL = "image/jpeg,image/png,image/webp,video/mp4,video/quicktime,audio/mpeg,audio/wav";
const ACCEPT_IMAGE = "image/jpeg,image/png,image/webp";
const ACCEPT_VIDEO = "video/mp4,video/quicktime";
const ACCEPT_AUDIO = "audio/mpeg,audio/wav,audio/x-wav,audio/wave";
type UploadKind = "image" | "video" | "audio";
function RefThumb({ item, onRemove }: { item: LocalRef; onRemove: () => void }) {
return (
@@ -59,7 +61,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
);
}
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onOpenPlatformLibrary, onSend }: {
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onSend }: {
mode: FreeMode;
model: string;
ratio: string;
@@ -81,19 +83,16 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
onDurationChange: (duration: number) => void;
onSeedChange: (seed: number) => void;
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void;
onSend: () => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const pendingRoleRef = useRef<"first_frame" | "last_frame" | undefined>(undefined);
const [dragOver, setDragOver] = useState(false);
const [hasPrompt, setHasPrompt] = useState(false);
const pickFiles = (role?: "first_frame" | "last_frame") => {
pendingRoleRef.current = role;
const pickFiles = (kind: UploadKind) => {
if (fileRef.current) {
fileRef.current.accept = mode === "keyframe" ? ACCEPT_IMAGE : ACCEPT_UNIVERSAL;
fileRef.current.multiple = mode === "universal";
fileRef.current.accept = kind === "image" ? ACCEPT_IMAGE : kind === "video" ? ACCEPT_VIDEO : ACCEPT_AUDIO;
fileRef.current.multiple = true;
fileRef.current.click();
}
};
@@ -120,21 +119,20 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (files.length) onFiles(files, pendingRoleRef.current);
pendingRoleRef.current = undefined;
if (files.length) onFiles(files);
}}
/>
<div className="fc-prompt-main">
{mode === "universal" ? (
<div className="fc-refs">
<button type="button" className="fc-ref-btn" title="上传参考素材(图≤9 / 视频≤3 / 音频≤3)" onClick={() => pickFiles()}>
<ImagePlus />
<button type="button" className="fc-ref-btn fc-ref-upload" title="上传参考图片,最多 9 张" aria-label="上传参考图片" onClick={() => pickFiles("image")}>
<ImagePlus /><span>图片</span>
</button>
<button type="button" className="fc-ref-btn" title="人物素材库" onClick={() => onOpenLibrary()}>
<UserRoundPlus />
<button type="button" className="fc-ref-btn fc-ref-upload" title="上传参考视频,最多 3 条" aria-label="上传参考视频" onClick={() => pickFiles("video")}>
<Film /><span>视频</span>
</button>
<button type="button" className="fc-ref-btn" title="引用平台素材(成品库 / 模特库 / 商品库)" onClick={() => onOpenPlatformLibrary()}>
<Images />
<button type="button" className="fc-ref-btn fc-ref-upload" title="上传参考音频,最多 3 条;需搭配图片或视频" aria-label="上传参考音频" onClick={() => pickFiles("audio")}>
<Music2 /><span>音频</span>
</button>
{refs.map((item) => (
<RefThumb key={item.key} item={item} onRemove={() => onRemoveRef(item.key)} />
@@ -151,7 +149,6 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
ref={promptRef}
refs={refs}
onSubmit={onSend}
onOpenLibrary={mode === "universal" ? () => onOpenLibrary() : undefined}
onTextChange={setHasPrompt}
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材,例如:产品在晨光下缓慢旋转,镜头推近展示包装细节……"}
/>
@@ -25,7 +25,6 @@ type Props = {
placeholder?: string;
disabled?: boolean;
onSubmit: () => void;
onOpenLibrary?: () => void;
onTextChange?: (hasText: boolean) => void;
};
@@ -58,7 +57,7 @@ function serializeNode(node: Node): string {
}
export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptInput(
{ refs, placeholder = "描述你想生成的视频,@ 可引用参考素材…", disabled, onSubmit, onOpenLibrary, onTextChange },
{ refs, placeholder = "描述你想生成的视频,@ 可引用参考素材…", disabled, onSubmit, onTextChange },
handleRef
) {
const editorRef = useRef<HTMLDivElement>(null);
@@ -121,6 +120,7 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
const closeMenu = () => { setMenuOpen(false); setMenuQuery(""); setMenuIndex(0); };
const openMenuAtCaret = () => {
if (labeledRefs.length === 0) return;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const rect = sel.getRangeAt(0).getBoundingClientRect();
@@ -240,25 +240,13 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
return () => document.removeEventListener("mousedown", onDown);
}, [menuOpen]);
const menuItems: { key: string; label: string; thumb?: string; kind: "ref" | "library" }[] = [
...candidates.map((r) => ({
const menuItems = candidates.map((r) => ({
key: r.key,
label: r.label || "",
thumb: r.thumb_url || (r.type === "image" ? r.url : ""),
kind: "ref" as const
})),
...(onOpenLibrary ? [{ key: "__library__", label: "从素材库选择…", kind: "library" as const }] : [])
];
thumb: r.thumb_url || (r.type === "image" ? r.url : "")
}));
const pickMenuItem = (item: (typeof menuItems)[number]) => {
if (item.kind === "library") {
// 先删掉触发串,再开素材库(选中后由页面 insertMention 插 chip)
const trigger = findTrigger();
trigger?.range.deleteContents();
closeMenu();
onOpenLibrary?.();
return;
}
insertChipAtTrigger(item.label, item.thumb);
};
@@ -330,14 +318,8 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
onMouseEnter={() => setMenuIndex(i)}
onMouseDown={(event) => { event.preventDefault(); pickMenuItem(item); }}
>
{item.kind === "ref" ? (
<>
{item.thumb ? <img src={item.thumb} alt="" /> : <span className="fc-mention-ph" />}
<span className="lbl">@{item.label}</span>
</>
) : (
<span className="lbl lib">{item.label}</span>
)}
</button>
))}
</div>
-7
View File
@@ -1134,13 +1134,6 @@ html[data-theme="dark"] .omni-session-page .omni-process-card .omni-process-ring
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-process-frame strong {
color: var(--accent-black);
}
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-process-meta {
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: .06em;
line-height: 1;
}
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-result-info {
border-top: 1px solid var(--border-faint);
background: var(--surface);
+12
View File
@@ -455,6 +455,18 @@
}
.fc-page .fc-ref-btn:hover { background: rgba(34, 42, 54, 0.07); }
.fc-page .fc-ref-btn svg { width: 18px; height: 18px; }
.fc-page .fc-ref-btn.fc-ref-upload {
width: 64px;
height: 54px;
display: flex;
flex-direction: column;
gap: 3px;
border-radius: var(--r-md);
font: inherit;
font-size: 10px;
font-weight: 500;
line-height: 1;
}
.fc-page .fc-ref {
position: relative;
width: 46px;
+12
View File
@@ -430,6 +430,18 @@
margin: 0;
}
/* 横向角色设定图需要留出三视图和两处脸部细节,预览时不得裁掉边缘。 */
.omni-person-reference-flow > .omni-result-card.is-person-reference,
.omni-person-reference-flow > .omni-process-card.is-person-reference {
flex-basis: 360px;
width: min(360px, 100%);
max-width: 360px;
}
.omni-result-card.is-person-reference .omni-result-tile img {
object-fit: contain;
}
.omni-strategy-card,
.omni-video-plan-card,
.omni-prompt-file-card,
+6 -8
View File
@@ -511,13 +511,12 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
}, [deleteTarget, detailId, stopPolling, notify]);
const openLibrary = useCallback((role?: "first_frame" | "last_frame") => {
if (mode === "keyframe" && !role) {
notify("info", "请点击首帧或尾帧槽位上的人物素材库入口");
return;
}
setLibraryTargetRole(role || null);
const target = mode === "keyframe"
? role || (refs.some((r) => r.role === "first_frame") ? "last_frame" : "first_frame")
: null;
setLibraryTargetRole(target);
setLibraryOpen(true);
}, [mode, notify]);
}, [mode, refs]);
const closeLibrary = useCallback(() => {
setLibraryOpen(false);
@@ -640,7 +639,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
<div className="fc-empty">
<div className="fc-empty-icon"><Clapperboard /></div>
<strong>准备生成新视频</strong>
<p>在下方输入提示词,或从素材库引用参考素材</p>
<p>在下方添加图片、视频或音频参考;也可从右上角引用素材库</p>
</div>
) : feedGroups.length === 0 ? (
<div className={`fc-empty${hasMore ? " is-soft" : ""}`}>
@@ -696,7 +695,6 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
onDurationChange={setDuration}
onSeedChange={setSeed}
onOpenLibrary={openLibrary}
onOpenPlatformLibrary={openPlatformLibrary}
onSend={() => void handleSend()}
/>
</div>
+1 -1
View File
@@ -667,7 +667,7 @@ export function OmniCreatePage({
model,
ratio,
...(outputMode === "video"
? { resolution, duration }
? { resolution, duration: selectedCase?.name === "剧情反转带货" ? "60 秒" : duration }
: { count: duration }),
},
})
+27 -6
View File
@@ -1351,9 +1351,14 @@ function ElicitCard({
const [customDirection, setCustomDirection] = useState("");
const [chatAnswer, setChatAnswer] = useState("");
const [personPromptOpen, setPersonPromptOpen] = useState(false);
const [personPrompt, setPersonPrompt] = useState("");
const [personPrompt, setPersonPrompt] = useState(() => String(message.payload.default_person_prompt || ""));
const productBriefNoteRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setPersonPrompt(String(message.payload.default_person_prompt || ""));
setPersonPromptOpen(false);
}, [message.id]);
useEffect(() => {
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
// 选素材字段要现拉候选:后端只给了 asset_types,具体有哪些素材是团队数据
@@ -1454,7 +1459,7 @@ function ElicitCard({
placeholder={
isPet
? "例如:呆萌可爱的金毛幼犬,毛发蓬松干净,系着小红领巾,眼神灵动"
: "单人例如:25岁女性,短发通勤风。多人物可写:角色1:年轻空乘短发;角色2:中年旅客长发"
: "可调整当前这位角色的年龄、发型、服装和气质"
}
onChange={(event) => setPersonPrompt(event.target.value)}
/>
@@ -1462,7 +1467,7 @@ function ElicitCard({
<span>
{isPet
? "不填写也可以,平台会结合当前商品和拟人主题自动设计萌宠角色。"
: "不填写也可以。若脚本是多人物,平台会按人数一次生成多张定妆图,避免长视频前后形象漂移。"}
: "已按视频 Prompt 中当前角色的身份预填。将生成一张横向设定图:左侧全身三视图,右侧正脸和侧脸特写;确认这一位后再准备下一位。"}
{Number(message.payload?.estimated_credits || 0) > 0
? ` 预计 ${Number(message.payload.estimated_credits)} 积分。`
: ""}
@@ -2184,13 +2189,14 @@ function ResultCard({
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const ratio = String(payload.ratio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
const personReferenceClass = payload.kind === "person_reference" ? " is-person-reference" : "";
const tileCount = showSegmentGrid ? assets.length : showSingleMedia ? 1 : 0;
const multiClass = tileCount > 1 ? "has-multiple" : "has-single";
const manyClass = tileCount > 4 ? " has-many" : "";
const displayAssets = showSegmentGrid ? assets : showSingleMedia ? assets.slice(0, 1) : [];
return (
<section className={`omni-result-card ${multiClass}${manyClass} ${ratioClass}`}>
<section className={`omni-result-card ${multiClass}${manyClass} ${ratioClass}${personReferenceClass}`}>
<div className={`omni-result-media${displayAssets.length > 1 ? " is-grid" : ""}`}>
{displayAssets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
@@ -2626,15 +2632,15 @@ function ProcessCard({
const completedSegments = Number(payload.completed_segment_count || 0);
const ratio = String(payload.ratio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
const personReferenceClass = payload.kind === "person_reference" ? " is-person-reference" : "";
return (
<section className={`omni-result-card omni-process-card has-single ${ratioClass}`}>
<section className={`omni-result-card omni-process-card has-single ${ratioClass}${personReferenceClass}`}>
<div className="omni-result-media">
<figure className="omni-result-tile">
<div className="omni-process-frame" aria-hidden="true">
<span className="omni-process-ring" />
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
<span className="omni-process-meta">[ RENDERING ]</span>
</div>
</figure>
</div>
@@ -2892,6 +2898,7 @@ export function OmniSessionPage({
// 3. 用 rAF 合并同一帧里的多次调用,不要每个 delta 都真滚一次。
const stickToBottomRef = useRef(true);
const scrollFrameRef = useRef(0);
const initialScrolledForRef = useRef<string | null>(null);
useEffect(() => {
const onScroll = () => {
@@ -2914,6 +2921,20 @@ export function OmniSessionPage({
});
}, []);
// 重进已有会话时,等详情与消息一起落到 DOM 后直接显示最新内容。
// 首次定位不能用 smooth:长历史的滚动动画会在中途触发 onScroll,误判成用户上滑。
useLayoutEffect(() => {
if (!conversation || conversation.id !== conversationId || initialScrolledForRef.current === conversationId) return;
initialScrolledForRef.current = conversationId;
stickToBottomRef.current = true;
if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current);
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
const frame = requestAnimationFrame(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
});
return () => cancelAnimationFrame(frame);
}, [conversation, conversationId]);
// 新消息落地:平滑滚一次
useEffect(() => {
scrollToBottom(true);