From c687ec4bbdd6a2019d570f22bf6c366f010dea73 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Tue, 29 Sep 2026 15:15:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=A8=E8=83=BD=E5=88=9B?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/creation.py | 12 +- core/backend/apps/ai/creation_agent.py | 368 ++++++++++++------ core/backend/apps/ai/creation_presets.py | 38 +- core/backend/apps/ai/serializers.py | 11 + core/backend/apps/ai/services.py | 10 +- core/backend/apps/ai/test_creation_agent.py | 253 ++++++++++-- .../apps/ai/test_standalone_image_routing.py | 25 ++ core/backend/apps/ai/views.py | 24 +- .../src/components/free-create/input-bar.tsx | 33 +- .../components/free-create/prompt-input.tsx | 36 +- core/frontend/src/dark-mode-pages.css | 7 - core/frontend/src/free-create-page.css | 12 + core/frontend/src/omni-session-page.css | 12 + core/frontend/src/routes/free-create.tsx | 14 +- core/frontend/src/routes/omni-create.tsx | 2 +- core/frontend/src/routes/omni-session.tsx | 33 +- 16 files changed, 632 insertions(+), 258 deletions(-) diff --git a/core/backend/apps/ai/creation.py b/core/backend/apps/ai/creation.py index 64c2105..0345c54 100644 --- a/core/backend/apps/ai/creation.py +++ b/core/backend/apps/ai/creation.py @@ -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 = "重新生成一个角色" diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 1265559..cd1c903 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -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], - ) - tasks = enqueue_standalone_images( - team=conversation.team, - user=user, - prompt=prompt, - mode="model", - count=1, - ratio="portrait", - feature="omni_create", - ) - task = tasks[0] - messages.append( - append_message( - conversation, - role="assistant", - kind=CreationMessage.Kind.GENERATING, - payload={ - "task_id": str(task.id), - "kind": "person_reference", - "prompt": prompt, - "label": label, - "cast_index": index, - "cast_total": total, - "cast_model_name": model_name, - }, - task=task, - ) - ) - return messages + 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_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=image_ratio, + feature="omni_create", + ) + task = tasks[0] + 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, + )] 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") diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index ca9fe33..5f4ec21 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -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] = { "用近景展示材质、动作或结果。商品全程保持正常完整;不以破损、漏液、渗水、超载或超出用途的测试制造戏剧性。" ), "剧情反转带货": ( - "【预设执行层·剧情反转带货】人物关系、冲突、情绪变化和反转必须在每个关键镜头连续推进。" + "【预设执行层·剧情反转带货】按同一条完整故事线连续推进开端、冲突、升级、反转和结局;" + "人物关系、目标与情绪在每个关键镜头都有因果变化,结尾回收开头问题而非停在故事中段。" "商品不是摆拍道具:必须充当解决问题的工具、解除误会的证据、关系变化的礼物或回收伏笔的关键物;" "商品出现、使用和结果必须直接改变剧情走向,禁止剧情结束后再硬插卖点。" ), diff --git a/core/backend/apps/ai/serializers.py b/core/backend/apps/ai/serializers.py index e23b0b3..28d967d 100644 --- a/core/backend/apps/ai/serializers.py +++ b/core/backend/apps/ai/serializers.py @@ -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) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 6aa365b..16b4824 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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(): diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 29f8979..ed103cf 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -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) diff --git a/core/backend/apps/ai/test_standalone_image_routing.py b/core/backend/apps/ai/test_standalone_image_routing.py index fcc07a2..1c3a35e 100644 --- a/core/backend/apps/ai/test_standalone_image_routing.py +++ b/core/backend/apps/ai/test_standalone_image_routing.py @@ -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") diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index ecfb3c2..18e3303 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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: diff --git a/core/frontend/src/components/free-create/input-bar.tsx b/core/frontend/src/components/free-create/input-bar.tsx index 00fe9f9..0bd203b 100644 --- a/core/frontend/src/components/free-create/input-bar.tsx +++ b/core/frontend/src/components/free-create/input-bar.tsx @@ -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(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); }} />
{mode === "universal" ? (
- - - {refs.map((item) => ( 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" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材,例如:产品在晨光下缓慢旋转,镜头推近展示包装细节……"} /> diff --git a/core/frontend/src/components/free-create/prompt-input.tsx b/core/frontend/src/components/free-create/prompt-input.tsx index 61eada2..accb0fb 100644 --- a/core/frontend/src/components/free-create/prompt-input.tsx +++ b/core/frontend/src/components/free-create/prompt-input.tsx @@ -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(function PromptInput( - { refs, placeholder = "描述你想生成的视频,@ 可引用参考素材…", disabled, onSubmit, onOpenLibrary, onTextChange }, + { refs, placeholder = "描述你想生成的视频,@ 可引用参考素材…", disabled, onSubmit, onTextChange }, handleRef ) { const editorRef = useRef(null); @@ -121,6 +120,7 @@ export const PromptInput = forwardRef(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(function PromptI return () => document.removeEventListener("mousedown", onDown); }, [menuOpen]); - const menuItems: { key: string; label: string; thumb?: string; kind: "ref" | "library" }[] = [ - ...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 }] : []) - ]; + const menuItems = candidates.map((r) => ({ + key: r.key, + label: r.label || "", + 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(function PromptI onMouseEnter={() => setMenuIndex(i)} onMouseDown={(event) => { event.preventDefault(); pickMenuItem(item); }} > - {item.kind === "ref" ? ( - <> - {item.thumb ? : } - @{item.label} - - ) : ( - {item.label} - )} + {item.thumb ? : } + @{item.label} ))}
diff --git a/core/frontend/src/dark-mode-pages.css b/core/frontend/src/dark-mode-pages.css index 03e8bef..7972efc 100644 --- a/core/frontend/src/dark-mode-pages.css +++ b/core/frontend/src/dark-mode-pages.css @@ -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); diff --git a/core/frontend/src/free-create-page.css b/core/frontend/src/free-create-page.css index 0d3c5a8..fcf2911 100644 --- a/core/frontend/src/free-create-page.css +++ b/core/frontend/src/free-create-page.css @@ -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; diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 3aa5809..930c18c 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -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, diff --git a/core/frontend/src/routes/free-create.tsx b/core/frontend/src/routes/free-create.tsx index 5b5d59a..d6fb8b5 100644 --- a/core/frontend/src/routes/free-create.tsx +++ b/core/frontend/src/routes/free-create.tsx @@ -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 }
准备生成新视频 -

在下方输入提示词,或从素材库引用参考素材

+

在下方添加图片、视频或音频参考;也可从右上角引用素材库

) : feedGroups.length === 0 ? (
@@ -696,7 +695,6 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack } onDurationChange={setDuration} onSeedChange={setSeed} onOpenLibrary={openLibrary} - onOpenPlatformLibrary={openPlatformLibrary} onSend={() => void handleSend()} />
diff --git a/core/frontend/src/routes/omni-create.tsx b/core/frontend/src/routes/omni-create.tsx index 1e7ae94..7fb7224 100644 --- a/core/frontend/src/routes/omni-create.tsx +++ b/core/frontend/src/routes/omni-create.tsx @@ -667,7 +667,7 @@ export function OmniCreatePage({ model, ratio, ...(outputMode === "video" - ? { resolution, duration } + ? { resolution, duration: selectedCase?.name === "剧情反转带货" ? "60 秒" : duration } : { count: duration }), }, }) diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index 3b3fe91..e6e1fb6 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -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(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({ {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 ( -
+
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 ( -
+
@@ -2892,6 +2898,7 @@ export function OmniSessionPage({ // 3. 用 rAF 合并同一帧里的多次调用,不要每个 delta 都真滚一次。 const stickToBottomRef = useRef(true); const scrollFrameRef = useRef(0); + const initialScrolledForRef = useRef(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);