@@ -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。"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"]:
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** 已保存的旧短剧会话保留原始预设值,界面统一显示新名称。 */
|
||||||
|
export function displayOmniPresetLabel(preset?: string | null): string {
|
||||||
|
if (preset === "剧情反转带货") return "短剧带货";
|
||||||
|
return preset || "自由创作";
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user