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

This commit is contained in:
Azmat@qq.com
2026-09-29 18:11:24 +08:00
parent c687ec4bbd
commit fa94051381
14 changed files with 669 additions and 183 deletions
+20 -4
View File
@@ -1,5 +1,7 @@
from pathlib import Path from pathlib import Path
import os import os
import sys
from urllib.parse import urlsplit
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -12,6 +14,20 @@ def env(name: str, default: str | None = None) -> str | None:
return os.getenv(name, default) return os.getenv(name, default)
def redis_url(name: str, database: int) -> str:
"""本机运行时把 Docker 内部 Redis 主机名映射到已启动的本机 Redis。
容器/Linux 与其他显式配置保持原样;不读取或输出 URL 中的凭证。
"""
fallback = f"redis://127.0.0.1:6379/{database}"
configured = env(name, fallback) or fallback
if sys.platform == "darwin" and urlsplit(configured).hostname == "yingqing-redis":
# Celery 自身也优先读取 CELERY_BROKER_URL 环境变量,只改 Django settings 不够。
os.environ[name] = fallback
return fallback
return configured
def env_bool(name: str, default: bool = False) -> bool: def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name) value = os.getenv(name)
if value is None: if value is None:
@@ -191,12 +207,12 @@ CORS_ALLOW_CREDENTIALS = True
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache", "BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": env("REDIS_CACHE_URL", "redis://127.0.0.1:6379/0"), "LOCATION": redis_url("REDIS_CACHE_URL", 0),
} }
} }
CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://127.0.0.1:6379/1") CELERY_BROKER_URL = redis_url("CELERY_BROKER_URL", 1)
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/2") CELERY_RESULT_BACKEND = redis_url("CELERY_RESULT_BACKEND", 2)
# 本地端到端联调开关:true = 任务同进程内联执行,不碰共享 Redis 队列(线上 worker 拓扑不受影响)。 # 本地端到端联调开关:true = 任务同进程内联执行,不碰共享 Redis 队列(线上 worker 拓扑不受影响)。
# 生产/集群绝不开;test.py 恒为 True。自重排类任务(poll_free_video_task)在 eager 下会跳过重排防递归。 # 生产/集群绝不开;test.py 恒为 True。自重排类任务(poll_free_video_task)在 eager 下会跳过重排防递归。
CELERY_TASK_ALWAYS_EAGER = str(env("CELERY_TASK_ALWAYS_EAGER", "false")).lower() == "true" CELERY_TASK_ALWAYS_EAGER = str(env("CELERY_TASK_ALWAYS_EAGER", "false")).lower() == "true"
@@ -236,7 +252,7 @@ CREATION_AGENT_MAX_OUTPUT_TOKENS = env_int("CREATION_AGENT_MAX_OUTPUT_TOKENS", 1
# 置空则不下发、跟随模型默认(= 开思考),仅在需要对比质量时才这么配。 # 置空则不下发、跟随模型默认(= 开思考),仅在需要对比质量时才这么配。
CREATION_AGENT_THINKING_MODE = (env("CREATION_AGENT_THINKING_MODE", "disabled") or "").strip() CREATION_AGENT_THINKING_MODE = (env("CREATION_AGENT_THINKING_MODE", "disabled") or "").strip()
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3") REDIS_LOCK_URL = redis_url("REDIS_LOCK_URL", 3)
def _tos_region() -> str: def _tos_region() -> str:
"""阿里云 OSS 的 S3 签名区域是 oss-cn-beijing 这种主机名前缀。火山 TOS 仍用 cn-shanghai。""" """阿里云 OSS 的 S3 签名区域是 oss-cn-beijing 这种主机名前缀。火山 TOS 仍用 cn-shanghai。"""
+21 -7
View File
@@ -83,7 +83,7 @@ def _meta_from_task(task, message: CreationMessage) -> dict:
model = task.model_config.display_name or task.model_config.name model = task.model_config.display_name or task.model_config.name
return { return {
"model": model or payload.get("model") or req.get("model") or "", "model": model or payload.get("model") or req.get("model") or "",
"ratio": req.get("ratio") or payload.get("ratio") or "", "ratio": req.get("aspect_ratio") or req.get("ratio") or payload.get("ratio") or "",
"prompt": payload.get("prompt") or req.get("prompt") or "", "prompt": payload.get("prompt") or req.get("prompt") or "",
} }
@@ -103,11 +103,15 @@ def _message_task(message: CreationMessage):
@transaction.atomic @transaction.atomic
def fail_generating_message(message: CreationMessage, error: str) -> CreationMessage: def fail_generating_message(message: CreationMessage, error: str) -> CreationMessage:
"""生成失败:GENERATING 原地改成 ERROR,不另开一条,避免中间态刷屏。""" """生成失败:GENERATING 原地改成 ERROR,不另开一条,避免中间态刷屏。"""
# worker 与会话轮询可能同时回填同一条消息;先锁会话再锁消息,和 append_message 的锁顺序一致。
conversation = CreationConversation.objects.select_for_update().get(pk=message.conversation_id)
message = CreationMessage.objects.select_for_update().get(pk=message.pk)
if message.kind != CreationMessage.Kind.GENERATING:
return message
is_person_reference = (message.payload or {}).get("kind") == "person_reference" is_person_reference = (message.payload or {}).get("kind") == "person_reference"
message.kind = CreationMessage.Kind.ERROR message.kind = CreationMessage.Kind.ERROR
message.text = (error or "生成失败")[:500] message.text = (error or "生成失败")[:500]
message.save(update_fields=["kind", "text", "updated_at"]) message.save(update_fields=["kind", "text", "updated_at"])
conversation = message.conversation
if is_person_reference: if is_person_reference:
memory = dict(conversation.memory or {}) memory = dict(conversation.memory or {})
memory["person_source_pending"] = False memory["person_source_pending"] = False
@@ -443,6 +447,7 @@ def start_segmented_video_merge(*, conversation: CreationConversation, message:
"source_message_id": str(message.id), "source_message_id": str(message.id),
"source_task_ids": task_ids, "source_task_ids": task_ids,
"prompt": payload.get("prompt") or "", "prompt": payload.get("prompt") or "",
"aspect_ratio": payload.get("ratio") or (source_tasks[0].request_payload or {}).get("aspect_ratio") or "",
}, },
) )
payload["merge_state"] = "queued" payload["merge_state"] = "queued"
@@ -453,7 +458,11 @@ def start_segmented_video_merge(*, conversation: CreationConversation, message:
conversation, conversation,
role="assistant", role="assistant",
kind=CreationMessage.Kind.GENERATING, kind=CreationMessage.Kind.GENERATING,
payload={"task_id": str(merge_task.id), "kind": "video_merge", "prompt": payload.get("prompt") or ""}, payload={
"task_id": str(merge_task.id), "kind": "video_merge",
"prompt": payload.get("prompt") or "",
"ratio": payload.get("ratio") or (source_tasks[0].request_payload or {}).get("aspect_ratio") or "",
},
task=merge_task, task=merge_task,
) )
return merge_task, generating return merge_task, generating
@@ -573,12 +582,13 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
重生成是**新开一条** GENERATING → RESULT,所以对话流仍然是往下叠加; 重生成是**新开一条** GENERATING → RESULT,所以对话流仍然是往下叠加;
这里改的只是同一次生成自己的中间态。 这里改的只是同一次生成自己的中间态。
""" """
# select_for_update 后重读状态:第二个回填者不能凭旧 GENERATING 对象再发一次确认。
conversation = CreationConversation.objects.select_for_update().get(pk=message.conversation_id)
message = CreationMessage.objects.select_for_update().get(pk=message.pk)
if message.kind != CreationMessage.Kind.GENERATING:
return message
original_payload = dict(message.payload or {}) original_payload = dict(message.payload or {})
is_person_reference = original_payload.get("kind") == "person_reference" is_person_reference = original_payload.get("kind") == "person_reference"
message.kind = CreationMessage.Kind.RESULT
message.payload = {**original_payload, **meta, "assets": assets}
message.save(update_fields=["kind", "payload", "updated_at"])
conversation = message.conversation
if is_person_reference: if is_person_reference:
from apps.assets.models import Asset, Model from apps.assets.models import Asset, Model
@@ -591,6 +601,10 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
).first() ).first()
if asset is None: if asset is None:
return fail_generating_message(message, "人物参考已生成,但未找到可锁定的图片资产") return fail_generating_message(message, "人物参考已生成,但未找到可锁定的图片资产")
message.kind = CreationMessage.Kind.RESULT
message.payload = {**original_payload, **meta, "assets": assets}
message.save(update_fields=["kind", "payload", "updated_at"])
if is_person_reference:
cast_index = int(original_payload.get("cast_index") or 1) cast_index = int(original_payload.get("cast_index") or 1)
cast_total = int(original_payload.get("cast_total") or 1) cast_total = int(original_payload.get("cast_total") or 1)
cast_model_name = str(original_payload.get("cast_model_name") or "").strip() cast_model_name = str(original_payload.get("cast_model_name") or "").strip()
+135 -22
View File
@@ -29,14 +29,17 @@ from django.db import transaction
from .creation import append_message, pin_refs from .creation import append_message, pin_refs
from .creation_presets import ( from .creation_presets import (
LEGACY_PLOT_TWIST_PRESET,
PLOT_TWIST_PRESET, PLOT_TWIST_PRESET,
apply_image_preset_prompt, apply_image_preset_prompt,
apply_plot_twist_story_contract, apply_plot_twist_story_contract,
apply_plot_twist_direction_contract, apply_plot_twist_direction_contract,
format_plot_twist_direction_contract, format_plot_twist_direction_contract,
apply_video_preset_prompt, apply_video_preset_prompt,
canonical_preset_name,
is_click_swap_preset, is_click_swap_preset,
is_fish_eye_outfit_preset, is_fish_eye_outfit_preset,
is_plot_twist_preset,
fish_eye_outfit_prompt_template, fish_eye_outfit_prompt_template,
plot_twist_story_depth, plot_twist_story_depth,
plot_twist_story_contract, plot_twist_story_contract,
@@ -255,6 +258,7 @@ _STEP_CONFIRM_LABELS = {
_PERSON_SOURCE_PRESETS = { _PERSON_SOURCE_PRESETS = {
"痛点解决演示", "痛点解决演示",
PLOT_TWIST_PRESET, PLOT_TWIST_PRESET,
LEGACY_PLOT_TWIST_PRESET,
"短剧反转带货", "短剧反转带货",
"达人口播种草", "达人口播种草",
"鱼眼换装", "鱼眼换装",
@@ -278,6 +282,7 @@ _CAST_RELATION_AUTO_RE = re.compile(r"(你来定|你安排|你决定|随便|都
_PRODUCT_REQUIRED_PRESETS = { _PRODUCT_REQUIRED_PRESETS = {
"痛点解决演示", "痛点解决演示",
PLOT_TWIST_PRESET, PLOT_TWIST_PRESET,
LEGACY_PLOT_TWIST_PRESET,
"达人口播种草", "达人口播种草",
"点击换款", "点击换款",
"多色商品换款", "多色商品换款",
@@ -290,7 +295,7 @@ _PRODUCT_REQUIRED_PRESETS = {
def is_plot_twist_conversation(conversation: CreationConversation) -> bool: def is_plot_twist_conversation(conversation: CreationConversation) -> bool:
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET return conversation.mode == CreationConversation.Mode.VIDEO and is_plot_twist_preset(conversation.preset)
@@ -594,6 +599,45 @@ _SELLING_POINT_FOCUS_RE = re.compile(
_SELLING_POINT_BRIEF_CREATIVE_RE = re.compile( _SELLING_POINT_BRIEF_CREATIVE_RE = re.compile(
r"(?:创作|做[一条]?|视频|帮我|生成|脚本|出片|参考|预设|方向)", r"(?:创作|做[一条]?|视频|帮我|生成|脚本|出片|参考|预设|方向)",
) )
_BRIEF_CLAUSE_END = r"[^。!?!?;;,,\n]{2,160}"
_BRIEF_AUDIENCE_RE = re.compile(
rf"(?:适用人群|目标人群|目标用户|适合人群|面向人群|受众)\s*(?:是|为|包括|:|:)\s*({_BRIEF_CLAUSE_END})"
)
_BRIEF_SCENARIO_RE = re.compile(
rf"(?:使用环境|使用场景|适用场景|拍摄场景|发生场景|场景)\s*(?:是|为|包括|设在|设定为|:|:)\s*({_BRIEF_CLAUSE_END})"
)
_BRIEF_ACTION_BENEFIT_RE = re.compile(
r"(?:^|[。!?!?;;,,]\s*)(?P<action>(?:把|将)[^。!?!?;;,,\n]{2,100}?"
r"(?:就|即可|便能|可以)[^。!?!?;;,,\n]{2,100})"
)
def extract_brief_context(text: str) -> dict[str, str]:
"""只采集用户明确说出的受众、场景和使用动作,不推断价格、功效等商品事实。"""
raw = (text or "").strip()
found: dict[str, str] = {}
for key, pattern in (("target_audience", _BRIEF_AUDIENCE_RE), ("usage_scenario", _BRIEF_SCENARIO_RE)):
matches = list(pattern.finditer(raw))
if matches:
value = matches[-1].group(1).strip(" ::。;;,, ")
if value and not re.match(r"^(?:和|与|及|或)?(?:核心卖点|使用场景|目标用户|价格)$", value):
found[key] = value[:160]
action = _BRIEF_ACTION_BENEFIT_RE.search(raw)
if action and not re.search(r"(?:不要|不能|禁止|别)\s*$", raw[max(0, action.start() - 8):action.start()]):
found["usage_benefit"] = action.group("action").strip()[:160]
return found
def collect_product_brief_context(conversation: CreationConversation, user_text: str = "") -> dict[str, str]:
"""按消息顺序合并商品 brief,后续补充覆盖先前描述。"""
found: dict[str, str] = {}
for message_text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True):
found.update(extract_brief_context(str(message_text or "")))
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
found.update(extract_brief_context(str(memory.get("product_brief_note") or "")))
if user_text:
found.update(extract_brief_context(user_text))
return found
def extract_declared_selling_point(text: str) -> str: def extract_declared_selling_point(text: str) -> str:
@@ -614,6 +658,11 @@ def extract_declared_selling_point(text: str) -> str:
value = value.strip("。;;!!??,,、 ") value = value.strip("。;;!!??,,、 ")
if len(value) >= 2: if len(value) >= 2:
return value[:200] return value[:200]
# 用户可能没有说「卖点」二字,却明确描述了商品使用动作及结果。
# 这里只复述该动作,不衍生「更快/更健康」等未经证实的功效。
action = extract_brief_context(raw).get("usage_benefit", "")
if action:
return action
# 整段就是短卖点列表(如「补水保湿,便于携带,买三送一」),且不像创作指令 # 整段就是短卖点列表(如「补水保湿,便于携带,买三送一」),且不像创作指令
if len(raw) <= 80 and not _SELLING_POINT_BRIEF_CREATIVE_RE.search(raw): if len(raw) <= 80 and not _SELLING_POINT_BRIEF_CREATIVE_RE.search(raw):
parts = [part.strip() for part in re.split(r"[,,、;;]", raw) if part.strip()] parts = [part.strip() for part in re.split(r"[,,、;;]", raw) if part.strip()]
@@ -655,7 +704,7 @@ def lock_selling_point(
memory = dict(conversation.memory or {}) memory = dict(conversation.memory or {})
memory["selling_point_ready"] = True memory["selling_point_ready"] = True
memory["selling_point_mode"] = mode memory["selling_point_mode"] = mode
memory["selling_point"] = selling_point if mode == "manual" else "" memory["selling_point"] = selling_point if mode in {"manual", "inferred"} else ""
conversation.memory = memory conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"]) conversation.save(update_fields=["memory", "updated_at"])
@@ -674,17 +723,19 @@ def ensure_selling_point_ready_from_context(
if memory.get("selling_point_ready"): if memory.get("selling_point_ready"):
return True return True
candidates: list[str] = [] candidates: list[tuple[str, str]] = []
declared = extract_declared_selling_point(user_text)
if declared:
candidates.append(declared)
for text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True): for text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True):
found = extract_declared_selling_point(text) found = extract_declared_selling_point(text)
if found: if found:
candidates.append(found) candidates.append((found, "inferred" if found == extract_brief_context(text).get("usage_benefit") else "manual"))
for source in (str(memory.get("product_brief_note") or ""), user_text):
found = extract_declared_selling_point(source)
if found:
candidates.append((found, "inferred" if found == extract_brief_context(source).get("usage_benefit") else "manual"))
if candidates: if candidates:
# 以最近一次声明为准(主页首条或后续补充) # 以最近一次声明为准(主页首条或后续补充)
lock_selling_point(conversation, candidates[-1], mode="manual") selling_point, mode = candidates[-1]
lock_selling_point(conversation, selling_point, mode=mode)
return True return True
summary = product_selling_points_summary(conversation) summary = product_selling_points_summary(conversation)
@@ -694,6 +745,17 @@ def ensure_selling_point_ready_from_context(
return False return False
def record_product_brief_review(conversation: CreationConversation, note: str = "") -> None:
"""提交商品补充时同步卖点状态,避免选完剧情方向后再次追问已填写的内容。"""
memory = dict(conversation.memory or {})
memory["product_brief_reviewed"] = True
if note:
memory["product_brief_note"] = note
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
ensure_selling_point_ready_from_context(conversation, note)
def append_selling_point_gate(conversation: CreationConversation) -> CreationMessage: def append_selling_point_gate(conversation: CreationConversation) -> CreationMessage:
"""在视频方案生成前确认卖点来源。 """在视频方案生成前确认卖点来源。
@@ -1117,6 +1179,20 @@ def product_brief_items(conversation: CreationConversation) -> list[dict]:
or "" or ""
).strip() ).strip()
selling = str(memory.get("selling_point") or "").strip() selling = str(memory.get("selling_point") or "").strip()
context = collect_product_brief_context(conversation)
audience = context.get("target_audience", "")
for ref in products:
if audience or ref.get("type") != "product":
continue
from apps.products.models import Product
audience = str(
Product.objects.filter(id=ref.get("id"), team_id=conversation.team_id)
.values_list("target_audience", flat=True).first() or ""
).strip()
scenario = context.get("usage_scenario", "")
audience_and_scenario = ";".join(
part for part in (f"目标用户:{audience}" if audience else "", f"使用场景:{scenario}" if scenario else "") if part
)
def item(label: str, status: str, value: str = "") -> dict: def item(label: str, status: str, value: str = "") -> dict:
return {"label": label, "status": status, "value": value} return {"label": label, "status": status, "value": value}
@@ -1131,7 +1207,7 @@ def product_brief_items(conversation: CreationConversation) -> list[dict]:
item("名称与品牌", "ready" if name else "missing", name), item("名称与品牌", "ready" if name else "missing", name),
item("品类与外观", "ready" if products else "missing", "将以已锁定商品图为准" if products else ""), item("品类与外观", "ready" if products else "missing", "将以已锁定商品图为准" if products else ""),
item("核心卖点", "ready" if selling else "missing", selling), item("核心卖点", "ready" if selling else "missing", selling),
item("使用场景与目标用户", "missing", ""), item("使用场景与目标用户", "ready" if audience and scenario else "missing", audience_and_scenario),
item("价格与优惠", "missing" if factual_price_requested else "not_needed", ""), item("价格与优惠", "missing" if factual_price_requested else "not_needed", ""),
item("特别强调或禁止内容", "ready" if special else "not_needed", special), item("特别强调或禁止内容", "ready" if special else "not_needed", special),
] ]
@@ -1144,6 +1220,20 @@ def product_brief_needs_review(conversation: CreationConversation) -> bool:
return has_locked_product_reference(conversation) and not bool(memory.get("product_brief_reviewed")) return has_locked_product_reference(conversation) and not bool(memory.get("product_brief_reviewed"))
def accept_complete_product_brief(conversation: CreationConversation) -> bool:
"""用户和商品库已给足必要信息时,跳过重复的核对问答。"""
if not product_brief_needs_review(conversation):
return False
items = product_brief_items(conversation)
if any(item["status"] == "missing" for item in items):
return False
memory = dict(conversation.memory or {})
memory["product_brief_reviewed"] = True
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return True
def append_product_brief_review(conversation: CreationConversation) -> CreationMessage: def append_product_brief_review(conversation: CreationConversation) -> CreationMessage:
"""商品信息只核对一次;已获取、待补充、暂不需要在一张卡里说清。""" """商品信息只核对一次;已获取、待补充、暂不需要在一张卡里说清。"""
return append_message( return append_message(
@@ -2024,7 +2114,7 @@ def emit_final_confirm_gate(
}, },
"submitted": False, "submitted": False,
"params": snapshot_session_params(conversation), "params": snapshot_session_params(conversation),
"param_options": confirm_param_options(True), "param_options": confirm_param_options(True, fixed_duration=is_plot_twist_conversation(conversation)),
}, },
) )
set_video_gate_stage(conversation, "confirm", pending_video_prompt=prompt) set_video_gate_stage(conversation, "confirm", pending_video_prompt=prompt)
@@ -2090,7 +2180,7 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
色彩与材质系统:写出主色、材质、皮肤/产品/环境的可见质感。 色彩与材质系统:写出主色、材质、皮肤/产品/环境的可见质感。
打光规则:光源方向、软硬、色温、人物和产品分别如何受光。 打光规则:光源方向、软硬、色温、人物和产品分别如何受光。
剪辑节奏:列出时间段与 Hook → 证据/体验 → 转化收束的推进逻辑。 剪辑节奏:列出时间段与 Hook → 证据/体验 → 转化收束的推进逻辑。
剧情反转带货例外:上述广告节奏让位于完整故事的起因→行动→受阻→升级→反转→结果;商品证据嵌入剧情因果,结局之后才自然收束,不要把 60 秒写成某一场戏的片段。 短剧带货例外:上述广告节奏让位于完整故事的起因→行动→受阻→升级→反转→结果;商品证据嵌入剧情因果,结局之后才自然收束,不要把 60 秒写成某一场戏的片段。
声音方向:人声身份、语气、语速、环境声/拟音、背景音乐的进入和收束;人声原文必须分配到对应分镜。 声音方向:人声身份、语气、语速、环境声/拟音、背景音乐的进入和收束;人声原文必须分配到对应分镜。
场景:逐一写清可见地点、前中后景、环境道具与景深。 场景:逐一写清可见地点、前中后景、环境道具与景深。
主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。多人物时先分行写「角色1:身份、性别、成年年龄段、发型与服装」「角色2:…」,每位角色单独一行,便于逐位生成定妆图;角色外观不得与剧情身份矛盾。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。 主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。多人物时先分行写「角色1:身份、性别、成年年龄段、发型与服装」「角色2:…」,每位角色单独一行,便于逐位生成定妆图;角色外观不得与剧情身份矛盾。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。
@@ -3028,6 +3118,8 @@ def apply_explicit_video_duration_from_text(
""" """
if conversation.mode != CreationConversation.Mode.VIDEO: if conversation.mode != CreationConversation.Mode.VIDEO:
return False return False
if is_plot_twist_conversation(conversation):
return False
match = _EXPLICIT_VIDEO_DURATION_RE.search(str(user_text or "")) match = _EXPLICIT_VIDEO_DURATION_RE.search(str(user_text or ""))
if match is None: if match is None:
return False return False
@@ -3057,6 +3149,8 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
stored = _PARAM_TO_STORED.get(str(key), str(key)) stored = _PARAM_TO_STORED.get(str(key), str(key))
if stored not in {"model", "ratio", "resolution", "duration", "count"}: if stored not in {"model", "ratio", "resolution", "duration", "count"}:
continue continue
if stored == "duration" and is_plot_twist_conversation(conversation):
continue
value = "、".join(raw) if isinstance(raw, list) else str(raw or "").strip() value = "、".join(raw) if isinstance(raw, list) else str(raw or "").strip()
if not value or current.get(stored) == value: if not value or current.get(stored) == value:
continue continue
@@ -3094,12 +3188,12 @@ def snapshot_session_params(conversation) -> dict:
} }
def confirm_param_options(is_video: bool) -> dict: def confirm_param_options(is_video: bool, *, fixed_duration: bool = False) -> dict:
return { return {
"model": VIDEO_MODELS if is_video else IMAGE_MODELS, "model": VIDEO_MODELS if is_video else IMAGE_MODELS,
"resolution": RESOLUTIONS if is_video else [], "resolution": RESOLUTIONS if is_video else [],
"ratio": RATIOS, "ratio": RATIOS,
"duration": VIDEO_DURATIONS if is_video else [], "duration": VIDEO_DURATIONS if is_video and not fixed_duration else [],
"count": IMAGE_COUNTS if not is_video else [], "count": IMAGE_COUNTS if not is_video else [],
} }
@@ -3119,7 +3213,11 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
old_duration = str(current.get("duration") or "") old_duration = str(current.get("duration") or "")
old_model = str(current.get("model") or "") old_model = str(current.get("model") or "")
changed = False changed = False
for key, raw in (incoming or {}).items(): confirmed = dict(incoming or {})
if conversation.mode == CreationConversation.Mode.VIDEO and is_plot_twist_conversation(conversation):
# 确认卡不开放时长;旧客户端和旧会话也必须回到固定的 60 秒。
confirmed["duration"] = "60 秒"
for key, raw in confirmed.items():
if key not in {"model", "ratio", "resolution", "duration", "count"}: if key not in {"model", "ratio", "resolution", "duration", "count"}:
continue continue
value = str(raw or "").strip() value = str(raw or "").strip()
@@ -3248,7 +3346,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"function": { "function": {
"name": "present_story_directions", "name": "present_story_directions",
"description": ( "description": (
"剧情反转带货预设默认按 60 秒完整短剧展开,先调用此工具展示 3 个可点击的剧情方向。" "短剧带货预设默认按 60 秒完整短剧展开,先调用此工具展示 3 个可点击的剧情方向。"
"每条都必须有不同的冲突、商品承担的实际作用、反转和情绪;不能只说‘我准备了三个方向’。" "每条都必须有不同的冲突、商品承担的实际作用、反转和情绪;不能只说‘我准备了三个方向’。"
"用户点击其一后才可 write_strategy;本工具调用后必须停下来等待选择。" "用户点击其一后才可 write_strategy;本工具调用后必须停下来等待选择。"
), ),
@@ -3319,7 +3417,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"声音/灯光/场景/参考素材锁定、可执行镜头和一致性收束,不要只写大纲。" "声音/灯光/场景/参考素材锁定、可执行镜头和一致性收束,不要只写大纲。"
) )
+ ( + (
f"剧情反转带货须先写【完整故事线】的{plot_beats}," f"短剧带货须先写【完整故事线】的{plot_beats},"
f"再按至少{plot_stage_count}段连续时间轴写完整的一集;结尾解决开头的问题,不能只交中间一场戏。" f"再按至少{plot_stage_count}段连续时间轴写完整的一集;结尾解决开头的问题,不能只交中间一场戏。"
if is_plot_twist_conversation(context.conversation) else "" if is_plot_twist_conversation(context.conversation) else ""
) )
@@ -4055,7 +4153,10 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
# 先落生成中消息,剩余片段会在槽位释放后由分批调度器继续提交。 # 先落生成中消息,剩余片段会在槽位释放后由分批调度器继续提交。
if len(segments) == 1: if len(segments) == 1:
message_payload = {"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt} message_payload = {
"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt,
"ratio": submit["aspect_ratio"],
}
else: else:
submitted_by_index = { submitted_by_index = {
int(segment["index"]): str(task.id) int(segment["index"]): str(task.id)
@@ -4066,6 +4167,7 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
"task_ids": [str(task.id) for task in tasks], "task_ids": [str(task.id) for task in tasks],
"kind": "video_segments", "kind": "video_segments",
"prompt": prompt, "prompt": prompt,
"ratio": submit["aspect_ratio"],
"total_duration": total_duration, "total_duration": total_duration,
"segments": [ "segments": [
{**segment, **({"task_id": submitted_by_index[int(segment["index"])]} if int(segment["index"]) in submitted_by_index else {})} {**segment, **({"task_id": submitted_by_index[int(segment["index"])]} if int(segment["index"]) in submitted_by_index else {})}
@@ -4352,6 +4454,11 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
f"- 商家已确认核心卖点:【{selling_point}】。策略、方案、脚本和出片指令必须围绕它展开;" f"- 商家已确认核心卖点:【{selling_point}】。策略、方案、脚本和出片指令必须围绕它展开;"
"只补充可从素材或正常使用中证明的支撑,不得替换或夸大。" "只补充可从素材或正常使用中证明的支撑,不得替换或夸大。"
) )
elif selling_mode == "inferred" and selling_point:
lines.append(
f"- 从用户原话归纳出的商品使用动作:【{selling_point}】。可作为创意切入点,"
"但不是已核实的功效、规格或价格;不得放大成未经证实的商品承诺。"
)
elif selling_mode == "auto": elif selling_mode == "auto":
lines.append( lines.append(
"- 商家已授权系统推荐卖点。你必须从商品资料、可见素材和正常使用动作中选择一个最易证明的核心卖点;" "- 商家已授权系统推荐卖点。你必须从商品资料、可见素材和正常使用动作中选择一个最易证明的核心卖点;"
@@ -4360,6 +4467,11 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
product_note = str(memory.get("product_brief_note") or "").strip() product_note = str(memory.get("product_brief_note") or "").strip()
if product_note: if product_note:
lines.append(f"- 用户在商品信息核对卡补充的真实事实:【{product_note}】。后续架构与 Prompt 必须沿用。") lines.append(f"- 用户在商品信息核对卡补充的真实事实:【{product_note}】。后续架构与 Prompt 必须沿用。")
brief_context = collect_product_brief_context(conversation)
if brief_context.get("target_audience"):
lines.append(f"- 用户指定的目标人群:【{brief_context['target_audience']}】。视频架构和 Prompt 必须体现。")
if brief_context.get("usage_scenario"):
lines.append(f"- 用户指定的使用/拍摄场景:【{brief_context['usage_scenario']}】。视频架构和 Prompt 必须体现。")
lines.append( lines.append(
"- 品牌、商品名、价格、优惠、规格、功效属于事实:缺失时只能询问或标待补充," "- 品牌、商品名、价格、优惠、规格、功效属于事实:缺失时只能询问或标待补充,"
"禁止用三个创意选项让用户从虚构事实里选择。创意方向可以给 3 个基于已知商品的建议并允许自定义。" "禁止用三个创意选项让用户从虚构事实里选择。创意方向可以给 3 个基于已知商品的建议并允许自定义。"
@@ -4411,7 +4523,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
if conversation.preset: if conversation.preset:
# 只给名字模型只能靠猜;把这个预设的拍法约束一起给它 # 只给名字模型只能靠猜;把这个预设的拍法约束一起给它
guidance = preset_guidance(conversation.preset) guidance = preset_guidance(conversation.preset)
lines.append(f"\n【创作预设】{conversation.preset}") lines.append(f"\n【创作预设】{canonical_preset_name(conversation.preset)}")
if context.is_video: if context.is_video:
lines.append( lines.append(
"预设必须贯穿本次对话:据此判断该补问哪些必要事实和素材、给什么创意选择、" "预设必须贯穿本次对话:据此判断该补问哪些必要事实和素材、给什么创意选择、"
@@ -4423,7 +4535,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
if is_plot_twist_conversation(conversation): if is_plot_twist_conversation(conversation):
depth = active_plot_twist_story_depth(conversation) depth = active_plot_twist_story_depth(conversation)
if not depth: if not depth:
lines.append("剧情反转带货默认 60 秒完整短剧;不要追问开场时长,先按 60 秒核对商品,再展示剧情方向。") lines.append("短剧带货默认 60 秒完整短剧;不要追问开场时长,先按 60 秒核对商品,再展示剧情方向。")
else: else:
lines.append(plot_twist_story_contract(depth)) lines.append(plot_twist_story_contract(depth))
memory = conversation.memory if isinstance(conversation.memory, dict) else {} memory = conversation.memory if isinstance(conversation.memory, dict) else {}
@@ -5112,6 +5224,7 @@ def iter_creation_agent_events(
yield {"type": "done"} yield {"type": "done"}
return return
accept_complete_product_brief(conversation)
if product_brief_needs_review(conversation): if product_brief_needs_review(conversation):
question = append_product_brief_review(conversation) question = append_product_brief_review(conversation)
set_video_gate_stage(conversation, "clarify") set_video_gate_stage(conversation, "clarify")
@@ -5917,7 +6030,7 @@ def _dispatch_tool(
if name == "present_story_directions": if name == "present_story_directions":
if not is_plot_twist_conversation(context.conversation): if not is_plot_twist_conversation(context.conversation):
return {"payload": {"error": "当前会话不是剧情反转带货预设"}}, False return {"payload": {"error": "当前会话不是短剧带货预设"}}, False
directions = _coerce_plot_twist_directions(args.get("directions")) directions = _coerce_plot_twist_directions(args.get("directions"))
if not directions: if not directions:
return { return {
@@ -5955,7 +6068,7 @@ def _dispatch_tool(
return { return {
"payload": { "payload": {
"error": ( "error": (
"剧情反转带货必须先选定剧情方向:调用 present_story_directions 展示 3 张方向卡," "短剧带货必须先选定剧情方向:调用 present_story_directions 展示 3 张方向卡,"
"等用户选择后再写策略。不得自行编造未选中的故事桥段。" "等用户选择后再写策略。不得自行编造未选中的故事桥段。"
) )
} }
@@ -6061,7 +6174,7 @@ def _dispatch_tool(
else: else:
return { return {
"payload": { "payload": {
"error": "剧情反转带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。" "error": "短剧带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。"
} }
}, False }, False
if not card["usp"] or not card["points"]: if not card["usp"] or not card["points"]:
+29 -18
View File
@@ -11,6 +11,20 @@ from __future__ import annotations
import re import re
CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"}) CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"})
PLOT_TWIST_PRESET = "短剧带货"
LEGACY_PLOT_TWIST_PRESET = "剧情反转带货"
def is_plot_twist_preset(name: str | None) -> bool:
"""新旧预设名称执行同一套短剧流程,兼容已保存的历史会话。"""
return str(name or "").strip() in {PLOT_TWIST_PRESET, LEGACY_PLOT_TWIST_PRESET}
def canonical_preset_name(name: str | None) -> str:
raw = str(name or "").strip()
return PLOT_TWIST_PRESET if raw == LEGACY_PLOT_TWIST_PRESET else raw
_CLICK_SWAP_FINGER_GUIDANCE = ( _CLICK_SWAP_FINGER_GUIDANCE = (
"形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序," "形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序,"
"画面只围绕同一件商品的逐款切换:机位、背景与灯光稳定,商品构图稳定。" "画面只围绕同一件商品的逐款切换:机位、背景与灯光稳定,商品构图稳定。"
@@ -39,8 +53,8 @@ VIDEO_PRESETS: dict[str, str] = {
"没有明确的防漏/承重/容量事实时,不用装水、尖锐物或超载来证明性能。" "没有明确的防漏/承重/容量事实时,不用装水、尖锐物或超载来证明性能。"
"用户没给关键功能或使用边界时,只问一次缺失事实;其余镜头用可观察的正常动作补齐。" "用户没给关键功能或使用边界时,只问一次缺失事实;其余镜头用可观察的正常动作补齐。"
), ),
"剧情反转带货": ( PLOT_TWIST_PRESET: (
"剧情反转带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物," "短剧带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物,"
"并在最终 Prompt、镜头、动作、对白和反转结果中反复可见地承担这个作用。" "并在最终 Prompt、镜头、动作、对白和反转结果中反复可见地承担这个作用。"
"不得在故事结束后突然停下来介绍商品,也不能只在最后两秒放商品图。" "不得在故事结束后突然停下来介绍商品,也不能只在最后两秒放商品图。"
"开场默认按 60 秒完整短剧结构写人物关系、持续冲突、商品介入时机、反转和情绪落点,不追问 15/30 秒故事深度;" "开场默认按 60 秒完整短剧结构写人物关系、持续冲突、商品介入时机、反转和情绪落点,不追问 15/30 秒故事深度;"
@@ -100,9 +114,6 @@ VIDEO_PRESETS: dict[str, str] = {
), ),
} }
PLOT_TWIST_PRESET = "剧情反转带货"
# 保留旧会话 15/30 秒的稳定标识以便回放;新会话不再展示故事深度选择卡。 # 保留旧会话 15/30 秒的稳定标识以便回放;新会话不再展示故事深度选择卡。
PLOT_TWIST_STORY_DEPTH_OPTIONS = ( PLOT_TWIST_STORY_DEPTH_OPTIONS = (
{ {
@@ -164,7 +175,7 @@ def plot_twist_story_contract(value: str) -> str:
depth = _PLOT_TWIST_DEPTH_BY_VALUE["60s"] depth = _PLOT_TWIST_DEPTH_BY_VALUE["60s"]
if depth["value"] == "15s": if depth["value"] == "15s":
return ( return (
"【剧情反转带货·15秒快节奏反转·强制执行】虽短也要有开端、冲突、反转和明确结局,不能只截取一场戏;" "【短剧带货·15秒快节奏反转·强制执行】虽短也要有开端、冲突、反转和明确结局,不能只截取一场戏;"
"只保留一条主冲突,主角 1 人、最多 1 名辅助人物。" "只保留一条主冲突,主角 1 人、最多 1 名辅助人物。"
"0-3 秒强冲突或意外,3-6 秒问题升级,6-11 秒商品以正常使用动作介入并证明一个核心卖点," "0-3 秒强冲突或意外,3-6 秒问题升级,6-11 秒商品以正常使用动作介入并证明一个核心卖点,"
"11-15 秒完成结果反转与自然行动引导。商品必须在中段前出现;对白短、直接、有记忆点;" "11-15 秒完成结果反转与自然行动引导。商品必须在中段前出现;对白短、直接、有记忆点;"
@@ -172,7 +183,7 @@ def plot_twist_story_contract(value: str) -> str:
) )
if depth["value"] == "30s": if depth["value"] == "30s":
return ( return (
"【剧情反转带货·30秒轻剧情带货·强制执行】写完人物目标从开端、冲突、反转到结局的因果线," "【短剧带货·30秒轻剧情带货·强制执行】写完人物目标从开端、冲突、反转到结局的因果线,"
"不可只写故事中段;必须交代人物处境,可设置两人互动。" "不可只写故事中段;必须交代人物处境,可设置两人互动。"
"0-4 秒抛出结果预告或冲突钩子,4-10 秒建立处境,10-17 秒让矛盾升级或一次错误尝试," "0-4 秒抛出结果预告或冲突钩子,4-10 秒建立处境,10-17 秒让矛盾升级或一次错误尝试,"
"17-24 秒商品在转折点以完整正常使用过程介入,24-28 秒回收反转与人物反应,28-30 秒自然收束。" "17-24 秒商品在转折点以完整正常使用过程介入,24-28 秒回收反转与人物反应,28-30 秒自然收束。"
@@ -180,7 +191,7 @@ def plot_twist_story_contract(value: str) -> str:
) )
if depth["value"] == "60s": if depth["value"] == "60s":
return ( return (
"【剧情反转带货·60秒完整短剧带货·强制执行】这是一集有开端和结局的独立短剧,不是一部长剧中截取的一段。" "【短剧带货·60秒完整短剧带货·强制执行】这是一集有开端和结局的独立短剧,不是一部长剧中截取的一段。"
"先写【完整故事线】,依次写清开端、冲突、升级、反转、结局;每一段都有前因后果,人物目标和关系最终有结果。" "先写【完整故事线】,依次写清开端、冲突、升级、反转、结局;每一段都有前因后果,人物目标和关系最终有结果。"
"0-5 秒可以用结果闪前作钩子,但随后必须回到故事起因;5-15 秒交代关系、目标与处境;" "0-5 秒可以用结果闪前作钩子,但随后必须回到故事起因;5-15 秒交代关系、目标与处境;"
"15-28 秒发生具体阻碍并升级;28-38 秒主角尝试、失败或作出选择;" "15-28 秒发生具体阻碍并升级;28-38 秒主角尝试、失败或作出选择;"
@@ -191,7 +202,7 @@ def plot_twist_story_contract(value: str) -> str:
) )
if depth["value"] == "180s": if depth["value"] == "180s":
return ( return (
"【剧情反转带货·180秒三幕完整故事·强制执行】全片按三个连续 60 秒章节推进,不能把三条短片简单拼接。" "【短剧带货·180秒三幕完整故事·强制执行】全片按三个连续 60 秒章节推进,不能把三条短片简单拼接。"
"第一章 0-60 秒:0-8 秒以结果预告或关系冲突抓人,8-28 秒建立人物目标与现实阻力," "第一章 0-60 秒:0-8 秒以结果预告或关系冲突抓人,8-28 秒建立人物目标与现实阻力,"
"28-48 秒埋入商品/SKU 与一个可见卖点证据,48-60 秒用第一次选择或失败把行动推入下一章。" "28-48 秒埋入商品/SKU 与一个可见卖点证据,48-60 秒用第一次选择或失败把行动推入下一章。"
"第二章 60-120 秒:承接上一章未完成动作,扩大矛盾并安排一次错误尝试;商品通过正常使用过程提供新的证据," "第二章 60-120 秒:承接上一章未完成动作,扩大矛盾并安排一次错误尝试;商品通过正常使用过程提供新的证据,"
@@ -207,7 +218,7 @@ def plot_twist_story_contract(value: str) -> str:
def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) -> str: def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) -> str:
"""在最终出片指令里确定性追加所选故事深度,避免模型漏写关键结构。""" """在最终出片指令里确定性追加所选故事深度,避免模型漏写关键结构。"""
base = str(prompt or "").strip() base = str(prompt or "").strip()
if (name or "").strip() != PLOT_TWIST_PRESET or not base: if not is_plot_twist_preset(name) or not base:
return base return base
contract = plot_twist_story_contract(story_depth) contract = plot_twist_story_contract(story_depth)
if not contract or contract in base: if not contract or contract in base:
@@ -277,7 +288,7 @@ def apply_plot_twist_direction_contract(prompt: str, *, title: str = "", conflic
VIDEO_PRESET_WORKFLOWS: dict[str, str] = { VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
"痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。", "痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。",
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。", "真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
"剧情反转带货": "默认按 60 秒完整短剧带货创作,不在开头追问或展示 15/30 秒故事深度选择;核对商品信息后给三个完整剧情方向。商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。", PLOT_TWIST_PRESET: "默认按 60 秒完整短剧带货创作,不在开头追问或展示 15/30 秒故事深度选择;核对商品信息后给三个完整剧情方向。商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。", "商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
"达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。", "达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。", "商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
@@ -304,8 +315,8 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
"【预设执行层·真实使用演示】像一镜到底可复核的日常实拍:先交代真实场景,再按正确用途完成步骤," "【预设执行层·真实使用演示】像一镜到底可复核的日常实拍:先交代真实场景,再按正确用途完成步骤,"
"用近景展示材质、动作或结果。商品全程保持正常完整;不以破损、漏液、渗水、超载或超出用途的测试制造戏剧性。" "用近景展示材质、动作或结果。商品全程保持正常完整;不以破损、漏液、渗水、超载或超出用途的测试制造戏剧性。"
), ),
"剧情反转带货": ( PLOT_TWIST_PRESET: (
"【预设执行层·剧情反转带货】按同一条完整故事线连续推进开端、冲突、升级、反转和结局;" "【预设执行层·短剧带货】按同一条完整故事线连续推进开端、冲突、升级、反转和结局;"
"人物关系、目标与情绪在每个关键镜头都有因果变化,结尾回收开头问题而非停在故事中段。" "人物关系、目标与情绪在每个关键镜头都有因果变化,结尾回收开头问题而非停在故事中段。"
"商品不是摆拍道具:必须充当解决问题的工具、解除误会的证据、关系变化的礼物或回收伏笔的关键物;" "商品不是摆拍道具:必须充当解决问题的工具、解除误会的证据、关系变化的礼物或回收伏笔的关键物;"
"商品出现、使用和结果必须直接改变剧情走向,禁止剧情结束后再硬插卖点。" "商品出现、使用和结果必须直接改变剧情走向,禁止剧情结束后再硬插卖点。"
@@ -411,12 +422,12 @@ ALL_PRESETS: dict[str, str] = {**VIDEO_PRESETS, **IMAGE_PRESETS}
def preset_guidance(name: str) -> str: def preset_guidance(name: str) -> str:
"""预设名 → 拍法约束。认不出的名字返回 "" —— 前端加了新卡但这里还没写时, """预设名 → 拍法约束。认不出的名字返回 "" —— 前端加了新卡但这里还没写时,
退回「只有名字」的行为,不要报错。""" 退回「只有名字」的行为,不要报错。"""
return ALL_PRESETS.get((name or "").strip(), "") return ALL_PRESETS.get(canonical_preset_name(name), "")
def preset_workflow_guidance(name: str) -> str: def preset_workflow_guidance(name: str) -> str:
"""预设名 → Agent 的素材、追问和检查重点。""" """预设名 → Agent 的素材、追问和检查重点。"""
return VIDEO_PRESET_WORKFLOWS.get((name or "").strip(), "") return VIDEO_PRESET_WORKFLOWS.get(canonical_preset_name(name), "")
_CLICK_SWAP_FINGER_CONTRACT = ( _CLICK_SWAP_FINGER_CONTRACT = (
@@ -439,7 +450,7 @@ _CLICK_SWAP_CHARACTER_CONTRACT = (
def video_preset_delivery_contract(name: str, *, click_swap_mode: str = "") -> str: def video_preset_delivery_contract(name: str, *, click_swap_mode: str = "") -> str:
"""预设名 → 最终视频生成阶段必须执行的结构与镜头约束。""" """预设名 → 最终视频生成阶段必须执行的结构与镜头约束。"""
preset = (name or "").strip() preset = canonical_preset_name(name)
if preset in CLICK_SWAP_PRESETS: if preset in CLICK_SWAP_PRESETS:
if (click_swap_mode or "").strip() == "character": if (click_swap_mode or "").strip() == "character":
return _CLICK_SWAP_CHARACTER_CONTRACT return _CLICK_SWAP_CHARACTER_CONTRACT
@@ -546,13 +557,13 @@ def scrub_click_swap_same_position_wording(text: str) -> str:
def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "") -> str: def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "") -> str:
"""把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。""" """把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。"""
base = str(prompt or "").strip() base = str(prompt or "").strip()
preset = (name or "").strip() preset = canonical_preset_name(name)
mode = (click_swap_mode or "").strip() mode = (click_swap_mode or "").strip()
contract = video_preset_delivery_contract(preset, click_swap_mode=mode) contract = video_preset_delivery_contract(preset, click_swap_mode=mode)
if not base or not contract: if not base or not contract:
return base return base
marker = f"【视频预设】{preset}" marker = f"【视频预设】{preset}"
if marker in base: if marker in base or (is_plot_twist_preset(name) and f"【视频预设】{LEGACY_PLOT_TWIST_PRESET}" in base):
return base return base
if is_click_swap_preset(preset): if is_click_swap_preset(preset):
if mode == "character": if mode == "character":
+8 -6
View File
@@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from .creation_presets import PLOT_TWIST_PRESET from .creation_presets import is_plot_twist_preset
from .models import ( from .models import (
AITask, AITask,
CreationConversation, CreationConversation,
@@ -248,18 +248,20 @@ class CreationConversationSerializer(serializers.ModelSerializer):
return progress return progress
def create(self, validated_data): def create(self, validated_data):
# 剧情反转带货的新会话直接以完整短剧的 60 秒开场,不再弹故事深度选择。 # 短剧带货现阶段固定为完整的 60 秒短剧,旧客户端传任何时长也不能覆盖。
# 旧客户端仍传「智能时长」时也要在服务端落成确定参数。 if validated_data.get("mode") == CreationConversation.Mode.VIDEO and is_plot_twist_preset(validated_data.get("preset")):
if validated_data.get("mode") == CreationConversation.Mode.VIDEO and validated_data.get("preset") == PLOT_TWIST_PRESET:
params = dict(validated_data.get("params") or {}) params = dict(validated_data.get("params") or {})
if not str(params.get("duration") or "").strip() or str(params.get("duration")).strip() == "智能时长": params["duration"] = "60 秒"
params["duration"] = "60 秒"
validated_data["params"] = params validated_data["params"] = params
return super().create(validated_data) return super().create(validated_data)
def update(self, instance, validated_data): def update(self, instance, validated_data):
# mode 定死:允许传但忽略,避免前端误改后顶栏参数与已生成内容对不上 # mode 定死:允许传但忽略,避免前端误改后顶栏参数与已生成内容对不上
validated_data.pop("mode", None) validated_data.pop("mode", None)
if instance.mode == CreationConversation.Mode.VIDEO and is_plot_twist_preset(validated_data.get("preset", instance.preset)):
params = dict(validated_data.get("params", instance.params) or {})
params["duration"] = "60 秒"
validated_data["params"] = params
return super().update(instance, validated_data) return super().update(instance, validated_data)
+204 -1
View File
@@ -4,6 +4,7 @@
工具调用拼装、追问中断、计费闸门、参考图带入、错误不打崩流。 工具调用拼装、追问中断、计费闸门、参考图带入、错误不打崩流。
""" """
import json import json
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
import requests import requests
@@ -30,6 +31,9 @@ from .creation_agent import (
apply_pain_point_direction, apply_pain_point_direction,
ensure_selling_point_ready_from_context, ensure_selling_point_ready_from_context,
extract_declared_selling_point, extract_declared_selling_point,
extract_brief_context,
product_brief_items,
accept_complete_product_brief,
apply_restart_intent, apply_restart_intent,
apply_person_identity_guard, apply_person_identity_guard,
apply_product_reference_guard, apply_product_reference_guard,
@@ -60,6 +64,7 @@ from .creation_agent import (
resolve_smart_video_duration, resolve_smart_video_duration,
is_continue_intent, is_continue_intent,
is_greeting, is_greeting,
is_plot_twist_conversation,
is_pure_chitchat, is_pure_chitchat,
is_restart_intent, is_restart_intent,
session_has_creative_context, session_has_creative_context,
@@ -623,6 +628,17 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
is_primary=True, is_primary=True,
) )
finish_generating_message(
generating,
assets=[{
"id": str(asset.id),
"url": "https://cdn.example/person-generated.jpg",
"cover": "https://cdn.example/person-generated.jpg",
"type": "image",
}],
meta={"prompt": "person"},
)
# worker 与页面轮询都拿着旧的 GENERATING 对象回填时,只能出现一次确认。
finish_generating_message( finish_generating_message(
generating, generating,
assets=[{ assets=[{
@@ -646,6 +662,9 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
self.assertTrue( self.assertTrue(
self.conversation.messages.filter(text__contains="是否使用这个角色").exists() self.conversation.messages.filter(text__contains="是否使用这个角色").exists()
) )
self.assertEqual(
self.conversation.messages.filter(text__contains="是否使用这个角色").count(), 1
)
def test_platform_person_submission_uses_model_mode(self): def test_platform_person_submission_uses_model_mode(self):
task = AITask.objects.create( task = AITask.objects.create(
@@ -1831,6 +1850,67 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(conversation.params["duration"], "60 秒") self.assertEqual(conversation.params["duration"], "60 秒")
self.assertEqual(conversation.params["ratio"], "9:16") self.assertEqual(conversation.params["ratio"], "9:16")
def test_short_drama_new_name_and_legacy_name_share_the_same_workflow(self):
from apps.ai.creation_presets import (
PLOT_TWIST_PRESET,
apply_plot_twist_story_contract,
apply_video_preset_prompt,
preset_guidance,
preset_workflow_guidance,
video_preset_delivery_contract,
)
from apps.ai.serializers import CreationConversationSerializer
self.assertEqual(PLOT_TWIST_PRESET, "短剧带货")
for name in ("短剧带货", "剧情反转带货"):
self.conversation.preset = name
self.assertTrue(is_plot_twist_conversation(self.conversation))
self.assertEqual(preset_guidance(name), preset_guidance("短剧带货"))
self.assertEqual(preset_workflow_guidance(name), preset_workflow_guidance("短剧带货"))
self.assertEqual(video_preset_delivery_contract(name), video_preset_delivery_contract("短剧带货"))
self.assertIn("【短剧带货·60秒", apply_plot_twist_story_contract(name, "60s", "完整短剧提示词"))
self.assertIn("【视频预设】短剧带货", apply_video_preset_prompt(name, "完整短剧提示词"))
serializer = CreationConversationSerializer(data={
"title": "短剧带货",
"mode": "video",
"preset": "短剧带货",
"params": {"ratio": "9:16"},
})
self.assertTrue(serializer.is_valid(), serializer.errors)
created = serializer.save(team=self.team, created_by=self.user)
self.assertEqual(created.params["duration"], "60 秒")
# 即使旧客户端在创建/修改会话时仍传其他时长,短剧也保持 60 秒。
serializer = CreationConversationSerializer(data={
"title": "短剧带货", "mode": "video", "preset": "短剧带货",
"params": {"duration": "15 秒", "ratio": "9:16"},
})
self.assertTrue(serializer.is_valid(), serializer.errors)
fixed = serializer.save(team=self.team, created_by=self.user)
self.assertEqual(fixed.params["duration"], "60 秒")
serializer = CreationConversationSerializer(fixed, data={"params": {"duration": "30 秒"}}, partial=True)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertEqual(serializer.save().params["duration"], "60 秒")
def test_short_drama_confirm_duration_is_fixed_and_legacy_session_rebuilds(self):
from .creation_agent import apply_confirm_params, confirm_param_options
self.conversation.preset = "短剧带货"
self.conversation.params = {**self.conversation.params, "duration": "60 秒"}
self.conversation.save(update_fields=["preset", "params", "updated_at"])
params, rebuild = apply_confirm_params(self.conversation, {"duration": "15 秒", "model": "Seedance 2.5"})
self.assertEqual(params["duration"], "60 秒")
self.assertFalse(rebuild)
self.assertEqual(confirm_param_options(True, fixed_duration=True)["duration"], [])
self.assertEqual([segment["duration"] for segment in plan_video_segments(60)], [30, 30])
self.conversation.params = {**self.conversation.params, "duration": "30 秒"}
self.conversation.save(update_fields=["params", "updated_at"])
params, rebuild = apply_confirm_params(self.conversation, {"duration": "30 秒"})
self.assertEqual(params["duration"], "60 秒")
self.assertTrue(rebuild)
def test_plot_twist_legacy_smart_choice_uses_60_second_story_contract(self): def test_plot_twist_legacy_smart_choice_uses_60_second_story_contract(self):
from apps.ai.creation_presets import plot_twist_story_contract from apps.ai.creation_presets import plot_twist_story_contract
@@ -1933,6 +2013,117 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
) )
self.assertEqual(fake.calls, []) self.assertEqual(fake.calls, [])
def test_natural_product_brief_uses_stated_audience_scene_and_action(self):
self._pin_product("空气炸锅")
self.conversation.preset = "短剧带货"
self.conversation.memory = {}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
brief = (
"使用此商品制作一条短剧带货视频,商品的适用人群是懒得下厨做饭的人以及来不及做饭的人,"
"将食材放进空气炸锅一炸就可以吃了。使用环境是家庭环境或公司午休环境。"
)
append_message(self.conversation, role="user", text=brief)
self.assertTrue(ensure_selling_point_ready_from_context(self.conversation, brief))
items = {item["label"]: item for item in product_brief_items(self.conversation)}
self.assertEqual(items["核心卖点"]["status"], "ready")
self.assertIn("将食材放进空气炸锅", items["核心卖点"]["value"])
self.assertEqual(items["使用场景与目标用户"]["status"], "ready")
self.assertIn("懒得下厨做饭的人", items["使用场景与目标用户"]["value"])
self.assertIn("公司午休环境", items["使用场景与目标用户"]["value"])
self.assertEqual(items["价格与优惠"]["status"], "not_needed")
self.assertTrue(accept_complete_product_brief(self.conversation))
self.conversation.refresh_from_db()
self.assertTrue(self.conversation.memory["product_brief_reviewed"])
system = build_system_prompt(AgentContext(conversation=self.conversation, user=self.user, model_config=self.model))
self.assertIn("懒得下厨做饭的人", system)
self.assertIn("公司午休环境", system)
def test_product_brief_does_not_invent_missing_claims(self):
self._pin_product("空气炸锅")
self.conversation.preset = "短剧带货"
self.conversation.memory = {}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
append_message(self.conversation, role="user", text="面向人群是上班族。价格是多少?做个短剧。")
self.assertFalse(ensure_selling_point_ready_from_context(self.conversation))
items = {item["label"]: item for item in product_brief_items(self.conversation)}
self.assertEqual(items["核心卖点"]["status"], "missing")
self.assertEqual(items["使用场景与目标用户"]["status"], "missing")
self.assertIn("上班族", items["使用场景与目标用户"]["value"])
self.assertEqual(items["价格与优惠"]["status"], "missing")
self.assertFalse(accept_complete_product_brief(self.conversation))
self.assertEqual(extract_brief_context("重点讲清使用场景和目标用户"), {})
def test_complete_natural_brief_skips_redundant_review_in_stream(self):
self._pin_product("空气炸锅")
self.conversation.preset = "短剧带货"
self.conversation.memory = {}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
fake = FakeProvider([_text_chunks("继续创作")])
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,
))
interactions = [
event["message"]["payload"].get("interaction")
for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
]
self.assertNotIn("product_brief_review", interactions)
self.conversation.refresh_from_db()
self.assertTrue(self.conversation.memory.get("product_brief_reviewed"))
@override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local")
def test_review_card_note_is_locked_as_selling_point_before_story_choice(self):
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
self._pin_product("空气炸锅")
self.conversation.preset = "短剧带货"
self.conversation.memory = {}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
review = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT,
payload={"interaction": "product_brief_review", "submitted": False, "answers": {}},
)
note = (
"商品的适用人群是懒得下厨做饭的人以及来不及做饭的人,"
"将食材放进空气炸锅一炸就可以吃了。使用环境是家庭环境或公司午休环境。"
)
client = APIClient()
client.force_authenticate(self.user)
with patch("apps.ai.views.begin_agent_planning", return_value=True), \
patch("apps.ai.views.team_agent_busy", return_value=False), \
patch("apps.ai.views.run_creation_agent_turn_task") as task:
response = client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "elicit_answer", "reply_to": str(review.id), "answers": {
"review_action": "supplement", "product_brief_note": note,
}},
format="json",
)
self.assertEqual(response.status_code, 202)
self.assertTrue(task.apply_async.called)
self.conversation.refresh_from_db()
self.assertTrue(self.conversation.memory.get("selling_point_ready"))
self.assertIn("将食材放进空气炸锅", self.conversation.memory.get("selling_point", ""))
from .views import _plot_twist_direction_continuation
_plot_twist_direction_continuation(
self.conversation,
{"directions": [{"id": "office", "title": "午休误会", "conflict": "同事误会", "product_role": "空气炸锅做饭", "reversal": "误会解除"}]},
"office",
)
result, _ = _dispatch_tool(
AgentContext(conversation=self.conversation, user=self.user, model_config=self.model),
"write_strategy",
{"target": "上班族", "trust": "用户描述的使用动作", "belief": "操作方便", "direction": "办公室轻喜剧"},
)
self.assertNotEqual(result.get("payload", {}).get("field"), "selling_point")
self.assertFalse(self.conversation.messages.filter(
kind=CreationMessage.Kind.ELICIT, payload__interaction="selling_point_gate"
).exists())
def test_plot_twist_depth_syncs_duration_and_is_written_into_final_prompt(self): def test_plot_twist_depth_syncs_duration_and_is_written_into_final_prompt(self):
self.conversation.preset = "剧情反转带货" self.conversation.preset = "剧情反转带货"
self.conversation.save(update_fields=["preset", "updated_at"]) self.conversation.save(update_fields=["preset", "updated_at"])
@@ -2889,7 +3080,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
portrait_asset=portrait, portrait_asset=portrait,
) )
self.conversation.preset = "达人口播种草" self.conversation.preset = "达人口播种草"
self.conversation.params = {**self.conversation.params, "duration": "60 秒"} self.conversation.params = {**self.conversation.params, "duration": "60 秒", "ratio": "16:9"}
self.conversation.pinned_refs = [{"type": "model", "id": str(model.id), "name": model.name}] self.conversation.pinned_refs = [{"type": "model", "id": str(model.id), "name": model.name}]
self.conversation.save(update_fields=["preset", "params", "pinned_refs", "updated_at"]) self.conversation.save(update_fields=["preset", "params", "pinned_refs", "updated_at"])
card = append_message( card = append_message(
@@ -2922,6 +3113,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(error, "") self.assertEqual(error, "")
self.assertEqual(message.payload["kind"], "video_segments") self.assertEqual(message.payload["kind"], "video_segments")
self.assertEqual(message.payload["ratio"], "16:9")
self.assertEqual(submit.call_count, 2) self.assertEqual(submit.call_count, 2)
first = submit.call_args_list[0].kwargs["params"] first = submit.call_args_list[0].kwargs["params"]
second = submit.call_args_list[1].kwargs["params"] second = submit.call_args_list[1].kwargs["params"]
@@ -2929,6 +3121,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(first["seed"], second["seed"]) self.assertEqual(first["seed"], second["seed"])
self.assertEqual(first["references"][0]["asset_id"], str(portrait.id)) self.assertEqual(first["references"][0]["asset_id"], str(portrait.id))
for params in (first, second): for params in (first, second):
self.assertEqual(params["aspect_ratio"], "16:9")
self.assertTrue(params["prompt"].startswith("【画面洁净 · 最高优先级】")) self.assertTrue(params["prompt"].startswith("【画面洁净 · 最高优先级】"))
self.assertIn("【开场】这是全片的第一段", params["prompt"]) self.assertIn("【开场】这是全片的第一段", params["prompt"])
self.assertNotIn("限时8折", params["prompt"]) self.assertNotIn("限时8折", params["prompt"])
@@ -3141,6 +3334,16 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
class VideoParamParsingTests(TestCase): class VideoParamParsingTests(TestCase):
def test_video_result_ratio_reads_aspect_ratio_from_task(self):
from .creation import _meta_from_task
task = SimpleNamespace(
model_config_id=None,
request_payload={"aspect_ratio": "16:9", "prompt": "横屏视频"},
)
message = SimpleNamespace(payload={"ratio": "9:16"})
self.assertEqual(_meta_from_task(task, message)["ratio"], "16:9")
def test_duration_label_and_smart_fallback(self): def test_duration_label_and_smart_fallback(self):
self.assertEqual(video_duration({"duration": "15 秒"}), 15) self.assertEqual(video_duration({"duration": "15 秒"}), 15)
self.assertEqual(video_duration({"duration": "智能时长"}), SMART_DURATION) self.assertEqual(video_duration({"duration": "智能时长"}), SMART_DURATION)
+3 -12
View File
@@ -57,6 +57,7 @@ from .creation_agent import (
is_restart_intent, is_restart_intent,
multi_character_relation_needs_clarification, multi_character_relation_needs_clarification,
restore_gated_step_after_cancel, restore_gated_step_after_cancel,
record_product_brief_review,
get_video_gate_stage, get_video_gate_stage,
set_video_gate_stage, set_video_gate_stage,
sync_prompt_after_cast, sync_prompt_after_cast,
@@ -2087,12 +2088,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
force_creative_turn = True force_creative_turn = True
continuation_instruction = _plot_twist_depth_continuation(depth) continuation_instruction = _plot_twist_depth_continuation(depth)
elif payload.get("interaction") == "product_brief_review": elif payload.get("interaction") == "product_brief_review":
memory = dict(conversation.memory or {}) record_product_brief_review(conversation, text)
memory["product_brief_reviewed"] = True
if text:
memory["product_brief_note"] = text
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
payload["answers"] = { payload["answers"] = {
"review_action": "supplement" if text else "continue", "review_action": "supplement" if text else "continue",
"product_brief_note": text, "product_brief_note": text,
@@ -2715,12 +2711,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
note = str(answers.get("product_brief_note") or "").strip() note = str(answers.get("product_brief_note") or "").strip()
if action == "supplement" and not note: if action == "supplement" and not note:
return JsonResponse({"detail": "请填写要补充的真实商品信息"}, status=400) return JsonResponse({"detail": "请填写要补充的真实商品信息"}, status=400)
memory = dict(conversation.memory or {}) record_product_brief_review(conversation, note)
memory["product_brief_reviewed"] = True
if note:
memory["product_brief_note"] = note
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
text = "" text = ""
record_user_message = False record_user_message = False
force_creative_turn = True force_creative_turn = True
@@ -1,3 +1,4 @@
import os
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from django.test import SimpleTestCase, override_settings from django.test import SimpleTestCase, override_settings
@@ -7,6 +8,24 @@ from apps.common import celery_health
from apps.common.celery_health import require_worker_task, worker_consumes_queue from apps.common.celery_health import require_worker_task, worker_consumes_queue
class RedisLocalUrlTests(SimpleTestCase):
def test_macos_maps_docker_only_host_to_local_redis_for_celery_and_django(self):
from airshelf.settings import base
with patch.dict(os.environ, {"CELERY_BROKER_URL": "redis://:example@yingqing-redis:6379/1"}):
with patch.object(base.sys, "platform", "darwin"):
self.assertEqual(base.redis_url("CELERY_BROKER_URL", 1), "redis://127.0.0.1:6379/1")
self.assertEqual(os.environ["CELERY_BROKER_URL"], "redis://127.0.0.1:6379/1")
def test_container_keeps_configured_redis_host(self):
from airshelf.settings import base
configured = "redis://:example@yingqing-redis:6379/1"
with patch.dict(os.environ, {"CELERY_BROKER_URL": configured}):
with patch.object(base.sys, "platform", "linux"):
self.assertEqual(base.redis_url("CELERY_BROKER_URL", 1), configured)
class RequireWorkerTaskTests(SimpleTestCase): class RequireWorkerTaskTests(SimpleTestCase):
def setUp(self): def setUp(self):
celery_health._registered_task_cache.clear() celery_health._registered_task_cache.clear()
@@ -89,7 +89,7 @@ function canCreateSegmentedVideo(config: ModelConfig | undefined, model: string)
// 31–60 秒总时长会拆成 ≤30 秒片段(长视频 >60 秒入口已临时关闭)。 // 31–60 秒总时长会拆成 ≤30 秒片段(长视频 >60 秒入口已临时关闭)。
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这些总时长。 // 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这些总时长。
return modelDurations(config).some((seconds) => seconds >= 30) return modelDurations(config).some((seconds) => seconds >= 30)
|| /seedance\s*2\.5/i.test(model || ""); || normalizeModelKey(model).includes("seedance25");
} }
function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] { function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] {
@@ -111,6 +111,7 @@ export function OmniParamBar({
resolution, resolution,
ratio, ratio,
duration, duration,
fixedDuration = false,
catalogModels, catalogModels,
onModel, onModel,
onResolution, onResolution,
@@ -123,6 +124,7 @@ export function OmniParamBar({
resolution: string; resolution: string;
ratio: string; ratio: string;
duration: string; duration: string;
fixedDuration?: boolean;
catalogModels?: ModelConfig[]; catalogModels?: ModelConfig[];
onModel: (value: string) => void; onModel: (value: string) => void;
onResolution: (value: string) => void; onResolution: (value: string) => void;
@@ -141,10 +143,11 @@ export function OmniParamBar({
[catalogModels, model, capability], [catalogModels, model, capability],
); );
const models = withCurrent( const availableModels = catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS);
catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS), const compatibleModels = fixedDuration && isVideo
model, ? availableModels.filter((label) => canCreateSegmentedVideo(findCatalogModel(catalogModels, label, "video"), label))
); : [];
const models = withCurrent(fixedDuration && isVideo ? compatibleModels : availableModels, model);
const allowedRes = isVideo ? modelResolutions(selected) : []; const allowedRes = isVideo ? modelResolutions(selected) : [];
const resolutions = withCurrent(allowedRes.length ? allowedRes : OMNI_RESOLUTIONS, resolution); const resolutions = withCurrent(allowedRes.length ? allowedRes : OMNI_RESOLUTIONS, resolution);
const durations = withCurrent(durationLabelsForModel(selected, model, isVideo), duration); const durations = withCurrent(durationLabelsForModel(selected, model, isVideo), duration);
@@ -200,6 +203,7 @@ export function OmniParamBar({
if (nextRes.length && resolution && !nextRes.includes(resolution)) { if (nextRes.length && resolution && !nextRes.includes(resolution)) {
onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]); onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]);
} }
if (fixedDuration) return;
const nextDur = durationLabelsForModel(selected, model, true); const nextDur = durationLabelsForModel(selected, model, true);
const seconds = Number(String(duration || "").replace(/\D/g, "")); const seconds = Number(String(duration || "").replace(/\D/g, ""));
// 已选 >60 秒的旧会话:收回长视频入口后压回 60 秒。 // 已选 >60 秒的旧会话:收回长视频入口后压回 60 秒。
@@ -215,6 +219,12 @@ export function OmniParamBar({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected?.id, isVideo, model]); }, [selected?.id, isVideo, model]);
useEffect(() => {
if (!fixedDuration || !isVideo || !compatibleModels.length) return;
if (!canCreateSegmentedVideo(selected, model)) onModel(compatibleModels[0]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fixedDuration, isVideo, model, selected?.id, compatibleModels.join("|")]);
return ( return (
<> <>
<label className="omni-parameter omni-parameter-model"> <label className="omni-parameter omni-parameter-model">
@@ -250,7 +260,7 @@ export function OmniParamBar({
options={toOptions(withCurrent(OMNI_RATIOS, ratio))} options={toOptions(withCurrent(OMNI_RATIOS, ratio))}
/> />
</label> </label>
<div className={`omni-duration-control${isVideo ? "" : " is-image-count"}`} ref={wrapRef}> {fixedDuration ? null : <div className={`omni-duration-control${isVideo ? "" : " is-image-count"}`} ref={wrapRef}>
<button <button
type="button" type="button"
className="omni-duration-trigger" className="omni-duration-trigger"
@@ -321,7 +331,7 @@ export function OmniParamBar({
document.body, document.body,
) )
: null} : null}
</div> </div>}
</> </>
); );
} }
+5
View File
@@ -1249,6 +1249,11 @@ body.sidebar-collapsed .user::after { display: none; }
overflow: hidden; overflow: hidden;
animation: none; animation: none;
} }
.content:has(.omni-session-page) {
/* 对话页内部已按顶栏以下的视口高度排版;通用内容 padding 会让自动滚底遮住首条消息。 */
padding: 0;
animation: none;
}
@keyframes yz-page-enter { @keyframes yz-page-enter {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
+5
View File
@@ -0,0 +1,5 @@
/** 已保存的旧短剧会话保留原始预设值,界面统一显示新名称。 */
export function displayOmniPresetLabel(preset?: string | null): string {
if (preset === "剧情反转带货") return "短剧带货";
return preset || "自由创作";
}
+150 -78
View File
@@ -431,11 +431,40 @@
} }
/* 横向角色设定图需要留出三视图和两处脸部细节,预览时不得裁掉边缘。 */ /* 横向角色设定图需要留出三视图和两处脸部细节,预览时不得裁掉边缘。 */
.omni-person-reference-flow > .omni-result-card.is-person-reference, .omni-person-reference-flow > .omni-result-card.is-person-reference {
flex: 0 0 min(372px, 100%);
width: min(372px, 100%);
max-width: 372px;
}
/* 角色定妆图生成时占满整条流程区域;完成后恢复为可并排的大图卡。 */
.omni-result-card.omni-process-card.is-person-reference {
width: min(760px, calc(100% - 44px));
max-width: 760px;
height: 400px;
display: flex;
flex-direction: column;
}
.omni-person-reference-flow > .omni-process-card.is-person-reference { .omni-person-reference-flow > .omni-process-card.is-person-reference {
flex-basis: 360px; flex: 1 0 100%;
width: min(360px, 100%); width: 100%;
max-width: 360px; max-width: 100%;
}
.omni-process-card.is-person-reference .omni-result-media {
flex: 1;
min-height: 0;
}
.omni-process-card.is-person-reference .omni-result-tile {
height: 100%;
aspect-ratio: auto;
}
.omni-process-card.is-person-reference .omni-process-frame {
min-height: 0;
aspect-ratio: auto;
} }
.omni-result-card.is-person-reference .omni-result-tile img { .omni-result-card.is-person-reference .omni-result-tile img {
@@ -1535,6 +1564,11 @@
margin-left: 0; margin-left: 0;
} }
.omni-result-card.omni-process-card.is-person-reference {
width: 100%;
height: clamp(280px, 58vw, 400px);
}
.omni-strategy-grid, .omni-strategy-grid,
.omni-plan-points, .omni-plan-points,
.omni-plan-timeline, .omni-plan-timeline,
@@ -2572,27 +2606,47 @@
height: 14px; height: 14px;
} }
/* 发送中:不要文案;左侧柔和呼吸高亮,气泡本身不变形 */ /* 发送状态只留在原消息气泡内,不额外占一行。 */
.omni-chat-row.is-user .omni-chat-stack.is-pending {
padding-left: 6px;
}
.omni-chat-row.is-user .omni-chat-stack.is-pending::before {
content: "";
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 2px;
background: #5b6572;
animation: omniSendPulse 1.35s ease-in-out infinite;
pointer-events: none;
}
.omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble { .omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble {
background: color-mix(in srgb, var(--surface) 84%, transparent); flex-direction: row;
border-color: var(--black-alpha-16); align-items: center;
background: var(--black-alpha-4);
border-color: var(--black-alpha-12);
animation: omniPendingBubble 1.5s ease-in-out infinite;
}
.omni-send-dots {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 3px;
}
.omni-send-dots i {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
opacity: .35;
animation: omniSendDotPulse 1.1s ease-in-out infinite;
}
.omni-send-dots i:nth-child(2) { animation-delay: .18s; }
.omni-send-dots i:nth-child(3) { animation-delay: .36s; }
@keyframes omniSendDotPulse {
0%, 70%, 100% { opacity: .35; transform: translateY(0); }
35% { opacity: 1; transform: translateY(-3px); }
}
@keyframes omniPendingBubble {
0%, 100% { border-color: var(--black-alpha-12); }
50% { border-color: var(--black-alpha-24); }
}
@media (prefers-reduced-motion: reduce) {
.omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble,
.omni-send-dots i { animation: none; }
} }
.omni-send-spinner { .omni-send-spinner {
@@ -2601,45 +2655,75 @@
border-radius: 50%; border-radius: 50%;
} }
.omni-chat-bubble.is-thinking { .omni-chat-row.agent.is-live .omni-chat-bubble.omni-wait-card {
display: flex; position: relative;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
color: var(--black-alpha-64);
}
.omni-chat-bubble.is-reasoning {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
color: var(--black-alpha-64);
}
/* 规划过程不是一行 loading:给过程本身足够横向空间,历史在内部滚动。 */
.omni-chat-row.agent.is-live .omni-chat-bubble.is-reasoning {
width: min(620px, calc(100% - 44px)); width: min(620px, calc(100% - 44px));
max-width: min(620px, calc(100% - 44px)); max-width: min(620px, calc(100% - 44px));
align-items: stretch;
gap: 0;
overflow: hidden;
padding: 18px 20px 16px;
border-radius: var(--r-md);
color: var(--black-alpha-64);
}
.omni-wait-card::before,
.omni-wait-card::after {
position: absolute;
top: 0;
left: 0;
height: 3px;
content: "";
}
.omni-wait-card::before {
width: 100%;
background: linear-gradient(
90deg,
var(--border-muted) 0%,
var(--border-muted) 35%,
var(--heat-40) 50%,
var(--border-muted) 65%,
var(--border-muted) 100%
);
background-size: 200% 100%;
animation: omniWaitPending 2.6s linear infinite;
}
html[data-theme="dark"] .omni-wait-card::before {
background-image: linear-gradient(
90deg,
var(--background-base) 0%,
var(--background-base) 35%,
var(--heat-40) 50%,
var(--background-base) 65%,
var(--background-base) 100%
);
}
.omni-wait-card::after {
width: 100%;
background: var(--klein);
transform: scaleX(.06);
transform-origin: left center;
animation: omniWaitProgress 36s linear forwards;
} }
.omni-think-head { .omni-think-head {
display: inline-flex; display: flex;
align-items: center; align-items: center;
gap: 8px; min-height: 24px;
color: var(--accent-black);
font-size: 13px; font-size: 13px;
color: var(--black-alpha-64); font-weight: 600;
} }
.omni-think-log { .omni-think-log {
display: grid; display: grid;
gap: 8px; gap: 8px;
max-height: 248px; margin-top: 12px;
overflow-y: auto; padding-top: 12px;
padding: 10px 8px 2px 0;
border-top: 1px solid var(--border-faint); border-top: 1px solid var(--border-faint);
scrollbar-gutter: stable;
} }
.omni-think-log-row { .omni-think-log-row {
@@ -2673,27 +2757,17 @@
word-break: break-word; word-break: break-word;
} }
.omni-typing { @keyframes omniWaitProgress {
display: inline-flex; 0% { transform: scaleX(.06); }
align-items: center; 10% { transform: scaleX(.38); }
gap: 4px; 45% { transform: scaleX(.66); }
75% { transform: scaleX(.8); }
100% { transform: scaleX(.94); }
} }
.omni-typing i { @keyframes omniWaitPending {
width: 6px; from { background-position: 100% 0; }
height: 6px; to { background-position: -100% 0; }
border-radius: 50%;
background: var(--klein);
opacity: .35;
animation: omniTyping 1.1s ease-in-out infinite;
}
.omni-typing i:nth-child(2) { animation-delay: .15s; }
.omni-typing i:nth-child(3) { animation-delay: .3s; }
@keyframes omniTyping {
0%, 80%, 100% { opacity: .28; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-2px); }
} }
@keyframes omniCurrentStepPulse { @keyframes omniCurrentStepPulse {
@@ -2701,15 +2775,13 @@
50% { opacity: 1; } 50% { opacity: 1; }
} }
@keyframes omniSendPulse { @media (prefers-reduced-motion: reduce) {
0%, 100% { .omni-wait-card::before,
background: #5b6572; .omni-wait-card::after,
opacity: .45; .omni-think-log-row.is-current::before { animation: none; }
} .omni-wait-card::before { background: var(--border-muted); }
50% { html[data-theme="dark"] .omni-wait-card::before { background: var(--background-base); }
background: #8b95a3; .omni-wait-card::after { transform: scaleX(.6); }
opacity: 1;
}
} }
.omni-process-skeleton { .omni-process-skeleton {
+4 -3
View File
@@ -19,6 +19,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, ApiError } from "../api"; import { api, ApiError } from "../api";
import { displayOmniPresetLabel } from "../omni-preset-label";
import { findCatalogModel } from "../components/omni-param-bar"; import { findCatalogModel } from "../components/omni-param-bar";
import { modelResolutions } from "../components/free-create/constants"; import { modelResolutions } from "../components/free-create/constants";
import { ConfirmModal, MediaLightbox } from "../components/overlays"; import { ConfirmModal, MediaLightbox } from "../components/overlays";
@@ -46,7 +47,7 @@ type PresetItem = {
const VIDEO_PRESETS: PresetItem[] = [ const VIDEO_PRESETS: PresetItem[] = [
{ name: "痛点解决演示", category: "commerce", mode: "video", title: "痛点解决演示", desc: "从真实痛点到正常使用,再用看得见的过程证明商品价值。", starter: "基于商品的真实用途创作一条痛点解决演示:先让人看见困扰,再拍清使用步骤和真实结果,不夸张、不出现不合理的测试。", cover: "/assets/video-presets/pain-point-demo.jpg", previewVideo: "/assets/video-presets/pain-point-demo.mp4" }, { name: "痛点解决演示", category: "commerce", mode: "video", title: "痛点解决演示", desc: "从真实痛点到正常使用,再用看得见的过程证明商品价值。", starter: "基于商品的真实用途创作一条痛点解决演示:先让人看见困扰,再拍清使用步骤和真实结果,不夸张、不出现不合理的测试。", cover: "/assets/video-presets/pain-point-demo.jpg", previewVideo: "/assets/video-presets/pain-point-demo.mp4" },
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" }, { name: "短剧带货", category: "story", mode: "video", title: "短剧带货", desc: "用完整故事线和人物冲突建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条从起因到结局完整收束的短剧带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" },
{ name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/video-presets/creator-recommendation.jpg", previewVideo: "/assets/video-presets/creator-recommendation.mp4" }, { name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/video-presets/creator-recommendation.jpg", previewVideo: "/assets/video-presets/creator-recommendation.mp4" },
{ name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/video-presets/rhythm-outfit-change.jpg", previewVideo: "/assets/video-presets/rhythm-outfit-change.mp4" }, { name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/video-presets/rhythm-outfit-change.jpg", previewVideo: "/assets/video-presets/rhythm-outfit-change.mp4" },
{ name: "点击换款", category: "visual", mode: "video", title: "点击换款", desc: "固定商品与机位,手指每次点击都在原位切换下一款。", starter: "创作一条点击换款视频:使用单一固定机位和同一背景,商品始终保持同一位置和比例;每次手指点击商品时,立即在原位切换到下一个颜色或款式,按确认顺序逐款展示,最后以全款式总览收束。不做口播、剧情、换场景或普通商品使用演示。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" }, { name: "点击换款", category: "visual", mode: "video", title: "点击换款", desc: "固定商品与机位,手指每次点击都在原位切换下一款。", starter: "创作一条点击换款视频:使用单一固定机位和同一背景,商品始终保持同一位置和比例;每次手指点击商品时,立即在原位切换到下一个颜色或款式,按确认顺序逐款展示,最后以全款式总览收束。不做口播、剧情、换场景或普通商品使用演示。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" },
@@ -667,7 +668,7 @@ export function OmniCreatePage({
model, model,
ratio, ratio,
...(outputMode === "video" ...(outputMode === "video"
? { resolution, duration: selectedCase?.name === "剧情反转带货" ? "60 秒" : duration } ? { resolution, duration: selectedCase?.name === "短剧带货" ? "60 秒" : duration }
: { count: duration }), : { count: duration }),
}, },
}) })
@@ -931,7 +932,7 @@ export function OmniHistoryPage({
<h2>{item.title}</h2> <h2>{item.title}</h2>
<p> <p>
{[ {[
item.preset || "自由创作", displayOmniPresetLabel(item.preset),
item.mode === "video" ? "视频" : "图片", item.mode === "video" ? "视频" : "图片",
item.params?.model, item.params?.model,
item.params?.ratio, item.params?.ratio,
+49 -25
View File
@@ -23,6 +23,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api } from "../api"; import { api } from "../api";
import { displayOmniPresetLabel } from "../omni-preset-label";
import { findCatalogModel, normalizeDurationValue, OmniParamBar } from "../components/omni-param-bar"; import { findCatalogModel, normalizeDurationValue, OmniParamBar } from "../components/omni-param-bar";
import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants"; import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants";
import { MediaLightbox } from "../components/overlays"; import { MediaLightbox } from "../components/overlays";
@@ -1620,7 +1621,7 @@ function ElicitCard({
{selected ? ( {selected ? (
<button <button
type="button" type="button"
className="btn btn-sm btn-ghost" className="btn btn-ghost"
disabled={disabled} disabled={disabled}
onClick={() => setAnswers((prev) => ({ ...prev, story_direction: "" }))} onClick={() => setAnswers((prev) => ({ ...prev, story_direction: "" }))}
> >
@@ -1629,7 +1630,7 @@ function ElicitCard({
) : null} ) : null}
<button <button
type="button" type="button"
className="btn btn-sm btn-primary" className="btn btn-primary"
disabled={disabled || !selected} disabled={disabled || !selected}
onClick={() => onSubmit({ story_direction: selected }, [])} onClick={() => onSubmit({ story_direction: selected }, [])}
> >
@@ -2048,6 +2049,7 @@ function ConfirmCard({
message, message,
sessionParams, sessionParams,
isVideo, isVideo,
isShortDrama,
catalogModels, catalogModels,
disabled, disabled,
onConfirm, onConfirm,
@@ -2056,6 +2058,7 @@ function ConfirmCard({
message: CreationMessage; message: CreationMessage;
sessionParams: Record<string, string>; sessionParams: Record<string, string>;
isVideo: boolean; isVideo: boolean;
isShortDrama: boolean;
catalogModels?: ModelConfig[]; catalogModels?: ModelConfig[];
disabled: boolean; disabled: boolean;
onConfirm: (params: Record<string, string>) => void; onConfirm: (params: Record<string, string>) => void;
@@ -2066,7 +2069,7 @@ function ConfirmCard({
const payloadCredits = Number(message.payload.estimated_credits || 0); const payloadCredits = Number(message.payload.estimated_credits || 0);
const payloadParams = asStringMap(message.payload.params); const payloadParams = asStringMap(message.payload.params);
const snapshot = { ...sessionParams, ...payloadParams }; const snapshot = { ...sessionParams, ...payloadParams };
const [draft, setDraft] = useState(snapshot); const [draft, setDraft] = useState(() => isShortDrama && isVideo && !submitted ? { ...snapshot, duration: "60 秒" } : snapshot);
const [initialDuration] = useState(snapshot.duration || ""); const [initialDuration] = useState(snapshot.duration || "");
const cardIsVideo = message.payload.kind !== "image" && isVideo; const cardIsVideo = message.payload.kind !== "image" && isVideo;
const durationChanged = const durationChanged =
@@ -2112,6 +2115,7 @@ function ConfirmCard({
: generationPhase === "failed" ? "可重新确认方案后再生成" : generationPhase === "failed" ? "可重新确认方案后再生成"
: generationPhase === "running" ? "正在出片,请稍候" : generationPhase === "running" ? "正在出片,请稍候"
: generationPhase === "submitted" ? "正在提交生成" : generationPhase === "submitted" ? "正在提交生成"
: isShortDrama ? "短剧带货固定 60 秒;确认前可以调整模型、分辨率和画面比例。"
: "确认前可以改参数。只有改时长时会同步更新视频架构与 Prompt。"; : "确认前可以改参数。只有改时长时会同步更新视频架构与 Prompt。";
const foot = const foot =
generationPhase === "done" ? "已完成出片" generationPhase === "done" ? "已完成出片"
@@ -2137,6 +2141,7 @@ function ConfirmCard({
<div className="omni-confirm-params"> <div className="omni-confirm-params">
<OmniParamBar <OmniParamBar
isVideo={cardIsVideo} isVideo={cardIsVideo}
fixedDuration={cardIsVideo && isShortDrama}
disabled={disabled} disabled={disabled}
catalogModels={catalogModels} catalogModels={catalogModels}
model={draft.model || ""} model={draft.model || ""}
@@ -2173,8 +2178,10 @@ function ConfirmCard({
function ResultCard({ function ResultCard({
payload, payload,
fallbackRatio,
}: { }: {
payload: Record<string, unknown>; payload: Record<string, unknown>;
fallbackRatio?: string;
}) { }) {
const assets = (payload.assets as Array<Record<string, string>> | undefined) || []; const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
const first = assets[0] || {}; const first = assets[0] || {};
@@ -2187,7 +2194,7 @@ function ResultCard({
const showSegmentGrid = partialFailure && assets.length > 0; const showSegmentGrid = partialFailure && assets.length > 0;
const showSingleMedia = !showSegmentGrid && assets.length > 0; const showSingleMedia = !showSegmentGrid && assets.length > 0;
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null); const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const ratio = String(payload.ratio || "").trim(); const ratio = String(payload.ratio || fallbackRatio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16"; 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 personReferenceClass = payload.kind === "person_reference" ? " is-person-reference" : "";
const tileCount = showSegmentGrid ? assets.length : showSingleMedia ? 1 : 0; const tileCount = showSegmentGrid ? assets.length : showSingleMedia ? 1 : 0;
@@ -2622,15 +2629,18 @@ function groupVisibleCreationMessages(list: CreationMessage[]): CreationMessage[
function ProcessCard({ function ProcessCard({
payload, payload,
fallbackRatio,
}: { }: {
payload: Record<string, unknown>; payload: Record<string, unknown>;
fallbackRatio?: string;
}) { }) {
const isSegmentedVideo = payload.kind === "video_segments"; const isSegmentedVideo = payload.kind === "video_segments";
const isMerge = payload.kind === "video_merge"; const isMerge = payload.kind === "video_merge";
const isVideo = payload.kind === "video" || isSegmentedVideo || isMerge; const isVideo = payload.kind === "video" || isSegmentedVideo || isMerge;
const segmentCount = Array.isArray(payload.segments) ? payload.segments.length : 0; const segmentCount = Array.isArray(payload.segments) ? payload.segments.length : 0;
const completedSegments = Number(payload.completed_segment_count || 0); const completedSegments = Number(payload.completed_segment_count || 0);
const ratio = String(payload.ratio || "").trim(); const generationSpec = payload.generation_spec as Record<string, unknown> | undefined;
const ratio = String(payload.ratio || generationSpec?.aspect_ratio || fallbackRatio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16"; 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 personReferenceClass = payload.kind === "person_reference" ? " is-person-reference" : "";
@@ -2900,14 +2910,23 @@ export function OmniSessionPage({
const scrollFrameRef = useRef(0); const scrollFrameRef = useRef(0);
const initialScrolledForRef = useRef<string | null>(null); const initialScrolledForRef = useRef<string | null>(null);
const bottomScrollTarget = useCallback(() => {
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
const latest = feedRef.current?.lastElementChild as HTMLElement | null;
if (!latest || latest.getBoundingClientRect().height > window.innerHeight * 0.6) return maxScroll;
const topbarHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--topbar-height")) || 0;
const latestTop = latest.getBoundingClientRect().top + window.scrollY;
// 短对话或低视口时,滚到底部不能把最后一条消息推进 sticky 顶栏背后。
return Math.min(maxScroll, Math.max(0, latestTop - topbarHeight - 16));
}, []);
useEffect(() => { useEffect(() => {
const onScroll = () => { const onScroll = () => {
const gap = document.documentElement.scrollHeight - window.scrollY - window.innerHeight; stickToBottomRef.current = bottomScrollTarget() - window.scrollY < 120;
stickToBottomRef.current = gap < 120;
}; };
window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll); return () => window.removeEventListener("scroll", onScroll);
}, []); }, [bottomScrollTarget]);
const scrollToBottom = useCallback((smooth: boolean) => { const scrollToBottom = useCallback((smooth: boolean) => {
if (!stickToBottomRef.current) return; if (!stickToBottomRef.current) return;
@@ -2915,11 +2934,11 @@ export function OmniSessionPage({
scrollFrameRef.current = requestAnimationFrame(() => { scrollFrameRef.current = requestAnimationFrame(() => {
scrollFrameRef.current = 0; scrollFrameRef.current = 0;
window.scrollTo({ window.scrollTo({
top: document.documentElement.scrollHeight, top: bottomScrollTarget(),
behavior: smooth ? "smooth" : "instant", behavior: smooth ? "smooth" : "instant",
}); });
}); });
}, []); }, [bottomScrollTarget]);
// 重进已有会话时,等详情与消息一起落到 DOM 后直接显示最新内容。 // 重进已有会话时,等详情与消息一起落到 DOM 后直接显示最新内容。
// 首次定位不能用 smooth:长历史的滚动动画会在中途触发 onScroll,误判成用户上滑。 // 首次定位不能用 smooth:长历史的滚动动画会在中途触发 onScroll,误判成用户上滑。
@@ -2928,12 +2947,12 @@ export function OmniSessionPage({
initialScrolledForRef.current = conversationId; initialScrolledForRef.current = conversationId;
stickToBottomRef.current = true; stickToBottomRef.current = true;
if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current); if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current);
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); window.scrollTo({ top: bottomScrollTarget(), behavior: "instant" });
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); window.scrollTo({ top: bottomScrollTarget(), behavior: "instant" });
}); });
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [conversation, conversationId]); }, [bottomScrollTarget, conversation, conversationId]);
// 新消息落地:平滑滚一次 // 新消息落地:平滑滚一次
useEffect(() => { useEffect(() => {
@@ -3729,6 +3748,7 @@ export function OmniSessionPage({
message={message} message={message}
sessionParams={params} sessionParams={params}
isVideo={isVideo} isVideo={isVideo}
isShortDrama={Boolean(conversation?.preset && ["短剧带货", "剧情反转带货"].includes(conversation.preset))}
catalogModels={modelConfigs} catalogModels={modelConfigs}
generationPhase={generationPhase} generationPhase={generationPhase}
disabled={ disabled={
@@ -3745,6 +3765,7 @@ export function OmniSessionPage({
<ProcessCard <ProcessCard
key={message.clientKey || message.id} key={message.clientKey || message.id}
payload={message.payload} payload={message.payload}
fallbackRatio={params.ratio}
/> />
); );
case "result": case "result":
@@ -3752,6 +3773,7 @@ export function OmniSessionPage({
<ResultCard <ResultCard
key={message.clientKey || message.id} key={message.clientKey || message.id}
payload={message.payload} payload={message.payload}
fallbackRatio={params.ratio}
/> />
); );
default: { default: {
@@ -3786,6 +3808,11 @@ export function OmniSessionPage({
{(body || message.role !== "user") ? ( {(body || message.role !== "user") ? (
<div className="omni-chat-bubble" aria-busy={pending || undefined}> <div className="omni-chat-bubble" aria-busy={pending || undefined}>
{body ? (message.role === "assistant" ? <ChatMarkdown text={body} /> : <p className="omni-chat-text">{body}</p>) : null} {body ? (message.role === "assistant" ? <ChatMarkdown text={body} /> : <p className="omni-chat-text">{body}</p>) : null}
{pending ? (
<span className="omni-send-dots" role="status" aria-label="发送中">
<i aria-hidden="true" /><i aria-hidden="true" /><i aria-hidden="true" />
</span>
) : null}
</div> </div>
) : null} ) : null}
{showReplyGuide ? ( {showReplyGuide ? (
@@ -3848,7 +3875,7 @@ export function OmniSessionPage({
<h1>{conversation?.title || "未命名创作"}</h1> <h1>{conversation?.title || "未命名创作"}</h1>
<div className="omni-session-meta"> <div className="omni-session-meta">
{[ {[
conversation?.preset || "自由创作", displayOmniPresetLabel(conversation?.preset),
params.model, params.model,
params.resolution && params.ratio params.resolution && params.ratio
? `${params.resolution} · ${params.ratio}` ? `${params.resolution} · ${params.ratio}`
@@ -3903,7 +3930,7 @@ export function OmniSessionPage({
&& Boolean(conversation?.agent_progress) && Boolean(conversation?.agent_progress)
&& !currentPlanningHasError ? ( && !currentPlanningHasError ? (
/生成图片|生成视频/.test(activeTool) && !liveText ? ( /生成图片|生成视频/.test(activeTool) && !liveText ? (
<ProcessCard payload={{ kind: /生成视频/.test(activeTool) ? "video" : "image" }} /> <ProcessCard payload={{ kind: /生成视频/.test(activeTool) ? "video" : "image" }} fallbackRatio={params.ratio} />
) : liveText ? ( ) : liveText ? (
<div className="omni-chat-row agent is-live"> <div className="omni-chat-row agent is-live">
<span className="omni-chat-avatar"> <span className="omni-chat-avatar">
@@ -3914,22 +3941,20 @@ export function OmniSessionPage({
</div> </div>
</div> </div>
) : ( ) : (
<div className="omni-chat-row agent is-live" aria-label="正在回复"> <div className="omni-chat-row agent is-live" aria-label="正在生成创作内容">
<span className="omni-chat-avatar"> <span className="omni-chat-avatar">
<Sparkles /> <Sparkles />
</span> </span>
<div className={`omni-chat-bubble${liveReasoning ? " is-reasoning" : " is-thinking"}`}> <div className="omni-chat-bubble omni-wait-card" role="status" aria-live="polite">
<div className="omni-think-head"> <div className="omni-think-head">
<span className="omni-typing" aria-hidden="true"><i /><i /><i /></span>
<span>{activeTool || (liveReasoning ? "正在思考" : isVideo ? "正在整理方案" : "正在整理画面")}</span> <span>{activeTool || (liveReasoning ? "正在思考" : isVideo ? "正在整理方案" : "正在整理画面")}</span>
</div> </div>
{liveReasoning ? ( <div className="omni-think-log" ref={thinkingLogRef} aria-label="创作过程">
<div className="omni-think-log" ref={thinkingLogRef} aria-label="创作过程"> {(visibleProgressHistory.length
{(visibleProgressHistory.length ? visibleProgressHistory.slice(-2)
? visibleProgressHistory
: [{ : [{
label: activeTool || "正在思考", label: activeTool || "正在思考",
detail: liveReasoning, detail: liveReasoning || "正在准备创作内容,请稍候…",
}] }]
).map((progress, index, history) => ( ).map((progress, index, history) => (
<div <div
@@ -3939,8 +3964,7 @@ export function OmniSessionPage({
<p>{progress.detail}</p> <p>{progress.detail}</p>
</div> </div>
))} ))}
</div> </div>
) : null}
</div> </div>
</div> </div>
) )