大量优化全能创作
This commit is contained in:
@@ -232,6 +232,34 @@ def get_pending_video_prompt(conversation: CreationConversation) -> str:
|
||||
return str(memory.get("pending_video_prompt") or "").strip()
|
||||
|
||||
|
||||
def append_selling_point_gate(conversation: CreationConversation) -> CreationMessage:
|
||||
"""在视频方案生成前确认卖点来源。
|
||||
|
||||
商家可以直接给真实卖点;若交给系统,则后续只从商品资料和参考素材中选择可证实的表达,
|
||||
不把“系统推荐”误做成无依据的夸大文案。
|
||||
"""
|
||||
return append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="这条视频准备先讲哪个卖点?你可以直接写真实卖点,也可以让我从商品和素材里推荐一个。",
|
||||
payload={
|
||||
"interaction": "selling_point_gate",
|
||||
"fields": [
|
||||
{
|
||||
"key": "selling_point",
|
||||
"label": "商品卖点",
|
||||
"type": "text",
|
||||
"required": False,
|
||||
"placeholder": "例如:油污一喷一擦就干净,适合厨房重油污…",
|
||||
}
|
||||
],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _step_confirm_payload(step: str) -> dict:
|
||||
return {
|
||||
"interaction": "step_confirm",
|
||||
@@ -438,8 +466,16 @@ IMAGE_MODEL_BY_LABEL = {
|
||||
"YQ image2": "gpt-image",
|
||||
"影擎-Image2": "gpt-image",
|
||||
}
|
||||
# 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值
|
||||
# 「智能时长」没有可读到的脚本时才用的保守兜底。实际方案生成后必须从时间轴推导,
|
||||
# 不能把每条片都悄悄压成 15 秒。
|
||||
SMART_DURATION = 15
|
||||
_SMART_DURATION_RE = re.compile(
|
||||
r"(?<!\d)(\d{1,2}(?:\.\d+)?)\s*(?:-|—|–|~|至|到)\s*(\d{1,2}(?:\.\d+)?)\s*秒?"
|
||||
)
|
||||
_TOTAL_DURATION_RE = re.compile(
|
||||
r"(?:总时长|成片时长|时长)\s*[::]?\s*(\d{1,2}(?:\.\d+)?)\s*秒",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 全能创作 video_prompt 写作规范。目标是把 Prompt 写成导演、摄影、声音与出片模型都能
|
||||
# 直接执行的制作文件,而不是只有几行「0-3 秒做什么」的提纲。
|
||||
@@ -876,6 +912,9 @@ def apply_restart_intent(conversation: CreationConversation) -> int:
|
||||
memory["stage"] = "clarify"
|
||||
memory.pop("pending_video_prompt", None)
|
||||
memory.pop("strategy_confirmed", None)
|
||||
memory.pop("selling_point_ready", None)
|
||||
memory.pop("selling_point_mode", None)
|
||||
memory.pop("selling_point", None)
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
|
||||
@@ -945,6 +984,17 @@ def requested_asset_card_from_context(
|
||||
)
|
||||
if not wants_card:
|
||||
return None
|
||||
# 用户明确要某类素材列表时,不能依赖上一条是否碰巧留下追问卡。
|
||||
# 否则模型会把检索结果念成名称段落,而不会返回可点击的卡片。
|
||||
direct_types = (
|
||||
("product", ("商品", "产品")),
|
||||
("character", ("角色", "人物")),
|
||||
("model", ("模特",)),
|
||||
("scene", ("场景",)),
|
||||
)
|
||||
for type_, keywords in direct_types:
|
||||
if any(keyword in text for keyword in keywords):
|
||||
return type_
|
||||
recent = conversation.messages.filter(
|
||||
kind=CreationMessage.Kind.ELICIT
|
||||
).order_by("-seq")[:8]
|
||||
@@ -1457,15 +1507,175 @@ def video_model_name(params: dict) -> str:
|
||||
return hit.name if hit else DEFAULT_VIDEO_MODEL
|
||||
|
||||
|
||||
def video_duration(params: dict) -> int:
|
||||
"""「15 秒」→ 15;「智能时长」/ 解析不出 → SMART_DURATION。"""
|
||||
def _is_smart_duration(params: dict) -> bool:
|
||||
raw = str((params or {}).get("duration") or "").strip().lower()
|
||||
return not raw or "智能" in raw or raw in {"smart", "auto"}
|
||||
|
||||
|
||||
def infer_script_duration(*, timeline: list[dict] | None = None, prompt: str = "") -> int | None:
|
||||
"""从已写好的方案推算实际片长:优先方案时间轴,其次 Prompt 的秒级分段。"""
|
||||
ends: list[float] = []
|
||||
for item in timeline or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
end = float(item.get("end"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if end > 0:
|
||||
ends.append(end)
|
||||
for match in _SMART_DURATION_RE.finditer(prompt or ""):
|
||||
try:
|
||||
ends.append(float(match.group(2)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for match in _TOTAL_DURATION_RE.finditer(prompt or ""):
|
||||
try:
|
||||
ends.append(float(match.group(1)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not ends:
|
||||
return None
|
||||
# 方案的最后一个时间点就是成片总长;向上取整避免 19.2 秒被截成 19 秒。
|
||||
return max(4, min(60, int(max(ends) + 0.999)))
|
||||
|
||||
|
||||
def _raw_script_duration(*, timeline: list[dict] | None = None, prompt: str = "") -> int | None:
|
||||
"""返回方案真实的最大时间点,不在这里截断,供 60 秒硬上限校验使用。"""
|
||||
ends: list[float] = []
|
||||
for item in timeline or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
end = float(item.get("end"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if end > 0:
|
||||
ends.append(end)
|
||||
for match in _SMART_DURATION_RE.finditer(prompt or ""):
|
||||
try:
|
||||
ends.append(float(match.group(2)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for match in _TOTAL_DURATION_RE.finditer(prompt or ""):
|
||||
try:
|
||||
ends.append(float(match.group(1)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return int(max(ends) + 0.999) if ends else None
|
||||
|
||||
|
||||
def plan_video_segments(duration: int, timeline: list[dict] | None = None) -> list[dict]:
|
||||
"""按方案节奏把 31–60 秒视频拆成可由 Seedance 2.5 单独完成的片段。
|
||||
|
||||
每段不超过 30 秒;优先落在时间轴的自然转场处,找不到合适转场再均分。
|
||||
当前模型上限下 31–60 秒稳定拆为两段,避免无意义地把同一支片拆得过碎。
|
||||
"""
|
||||
total = max(4, min(int(duration or 0), 60))
|
||||
if total <= 30:
|
||||
return [{"index": 1, "start": 0, "end": total, "duration": total}]
|
||||
|
||||
candidates: list[int] = []
|
||||
for item in timeline or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
end = int(round(float(item.get("end"))))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 4 <= end <= total - 4:
|
||||
candidates.append(end)
|
||||
# 两段都必须 <= 30 秒,因此 60 秒时唯一合法分点就是 30 秒。
|
||||
lower, upper = max(4, total - 30), min(30, total - 4)
|
||||
target = total / 2
|
||||
legal = [point for point in candidates if lower <= point <= upper]
|
||||
split = min(legal, key=lambda point: abs(point - target)) if legal else int(round(target))
|
||||
split = max(lower, min(upper, split))
|
||||
return [
|
||||
{"index": 1, "start": 0, "end": split, "duration": split},
|
||||
{"index": 2, "start": split, "end": total, "duration": total - split},
|
||||
]
|
||||
|
||||
|
||||
def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str:
|
||||
"""让单段模型只拍本段,不把整条长脚本压回每一个片段里。"""
|
||||
index = int(segment.get("index") or 1)
|
||||
start = int(segment.get("start") or 0)
|
||||
end = int(segment.get("end") or 0)
|
||||
return (
|
||||
f"{prompt.strip()}\n\n"
|
||||
f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}–{end} 秒的内容。"
|
||||
"仅呈现这一时间段对应的情节与镜头,承接上一段的角色、服装、商品、场景和光线,"
|
||||
"为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。"
|
||||
)
|
||||
|
||||
|
||||
def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | None = None) -> int:
|
||||
"""显式时长优先;智能时长从方案/Prompt 推导,完全缺失才回退 15 秒。"""
|
||||
raw = str(params.get("duration") or "")
|
||||
digits = "".join(ch for ch in raw if ch.isdigit())
|
||||
if not digits:
|
||||
inferred = infer_script_duration(timeline=timeline, prompt=prompt)
|
||||
if inferred is not None:
|
||||
return inferred
|
||||
return SMART_DURATION
|
||||
return max(4, min(int(digits), 60))
|
||||
|
||||
|
||||
def _model_supports_duration(model_name: str, duration: int) -> bool:
|
||||
"""仅用于智能时长的自动路由;配置缺失时不武断拦截,交由提交校验给出明确错误。"""
|
||||
model = ModelConfig.objects.filter(
|
||||
name=model_name,
|
||||
capability=ModelConfig.Capability.VIDEO,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
).first()
|
||||
if model is None:
|
||||
return True
|
||||
meta = model.metadata if isinstance(model.metadata, dict) else {}
|
||||
listed = (meta.get("capabilities") or {}).get("durations") or meta.get("durations") or []
|
||||
values = [int(value) for value in listed if str(value).isdigit()]
|
||||
return not values or min(values) <= duration <= max(values)
|
||||
|
||||
|
||||
def resolve_smart_video_duration(
|
||||
conversation: CreationConversation,
|
||||
*,
|
||||
prompt: str = "",
|
||||
timeline: list[dict] | None = None,
|
||||
) -> int:
|
||||
"""把智能时长固化为方案真实时长,并在需要时切换到能承载它的模型。
|
||||
|
||||
这一步发生在方案写完、用户看到最终确认卡之前,所以页面显示、计费和实际 API 参数
|
||||
始终是同一个秒数。
|
||||
"""
|
||||
params = dict(conversation.params or {})
|
||||
smart_duration = _is_smart_duration(params)
|
||||
duration = video_duration(params, prompt=prompt, timeline=timeline)
|
||||
if smart_duration:
|
||||
params["duration"] = f"{duration} 秒"
|
||||
selected_model = video_model_name(params)
|
||||
switched = False
|
||||
# Seedance 2.5 是当前唯一可稳定承载 16–30 秒单段、以及 31–60 秒分段的模型。
|
||||
# 即使总时长 45 秒不在单段能力表里,后续也会拆成两条 <=30 秒的 2.5 任务。
|
||||
if duration > 15 and selected_model != DEFAULT_VIDEO_MODEL:
|
||||
params["model"] = "Seedance 2.5"
|
||||
switched = True
|
||||
elif not _model_supports_duration(selected_model, duration):
|
||||
# 智能模式可自动选能完成完整脚本的模型;确认卡会清楚展示变更,用户仍能手动调整。
|
||||
if _model_supports_duration(DEFAULT_VIDEO_MODEL, duration):
|
||||
params["model"] = "Seedance 2.5"
|
||||
switched = True
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["smart_duration_resolved"] = duration
|
||||
if switched:
|
||||
memory["smart_duration_model_switched"] = True
|
||||
if smart_duration or switched:
|
||||
conversation.params = params
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["params", "memory", "updated_at"])
|
||||
return duration
|
||||
|
||||
|
||||
def _image_count(params: dict, raw) -> int:
|
||||
"""出图张数:首页选过「N 张」就用它,否则用模型传的 count,默认 1,上限 8。"""
|
||||
label = str((params or {}).get("count") or (params or {}).get("duration") or "")
|
||||
@@ -1534,7 +1744,6 @@ def _run_generate_image(context: AgentContext, args: dict) -> tuple[dict, list]:
|
||||
def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list]:
|
||||
"""拼 submit_free_video 的入参。references 直接用 resolve_refs 的产物 ——
|
||||
它已经排好 角色 → 场景 → 商品 的顺序,那正是出片模型 @图N 的语义依据。"""
|
||||
params = context.conversation.params or {}
|
||||
resolved = resolve_refs(context.team, context.conversation.pinned_refs or [])
|
||||
prompt = apply_video_preset_prompt(context.conversation.preset, prompt)
|
||||
prompt = apply_product_voice_visual_guard(context.conversation, prompt)
|
||||
@@ -1544,6 +1753,9 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
|
||||
active_plot_twist_story_depth(context.conversation),
|
||||
prompt,
|
||||
)
|
||||
duration = resolve_smart_video_duration(context.conversation, prompt=prompt)
|
||||
# resolve_smart_video_duration 可能为了完整脚本切到支持长时长的模型,必须重新取参数。
|
||||
params = context.conversation.params or {}
|
||||
submit = {
|
||||
"prompt": prompt,
|
||||
"feature": "omni_create",
|
||||
@@ -1551,7 +1763,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
|
||||
"model": video_model_name(params),
|
||||
"aspect_ratio": params.get("ratio") or "9:16",
|
||||
"resolution": params.get("resolution") or "720p",
|
||||
"duration": video_duration(params),
|
||||
"duration": duration,
|
||||
"generate_audio": True,
|
||||
"references": resolved.references,
|
||||
}
|
||||
@@ -1612,7 +1824,10 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
|
||||
**这里不再跑一轮模型**:方案已经确认过了,再让模型决定一次既费钱又可能它不调工具。
|
||||
返回 (生成中消息, 错误文案),两者必有其一。
|
||||
"""
|
||||
from .free_video import submit_free_video
|
||||
from django.conf import settings
|
||||
|
||||
from .free_video import IN_FLIGHT_STATUSES, submit_free_video
|
||||
from .models import AITask
|
||||
|
||||
payload = confirm_message.payload or {}
|
||||
prompt = str(payload.get("video_prompt") or "").strip()
|
||||
@@ -1621,14 +1836,54 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
|
||||
|
||||
context = AgentContext(conversation=conversation, user=user, model_config=None)
|
||||
submit, _references = _video_submit_params(context, prompt)
|
||||
total_duration = int(submit["duration"])
|
||||
segments = plan_video_segments(total_duration)
|
||||
# 先整体检查并发余量,再提交任何一段;否则第一段已扣费、第二段才因额度满失败会留下孤儿片段。
|
||||
in_flight = AITask.objects.filter(
|
||||
team=conversation.team,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status__in=IN_FLIGHT_STATUSES,
|
||||
).count()
|
||||
max_concurrent = int(getattr(settings, "FREE_VIDEO_MAX_CONCURRENT", 3))
|
||||
if in_flight + len(segments) > max_concurrent:
|
||||
return None, f"当前视频任务余量不足,需要同时生成 {len(segments)} 段;请等待现有任务完成后再试。"
|
||||
tasks = []
|
||||
try:
|
||||
task = submit_free_video(team=conversation.team, user=user, params=submit)
|
||||
for segment in segments:
|
||||
segment_submit = {
|
||||
**submit,
|
||||
"duration": int(segment["duration"]),
|
||||
"prompt": segment_video_prompt(prompt, segment, total_duration) if len(segments) > 1 else submit["prompt"],
|
||||
"extra_payload": {
|
||||
"omni_segment": {
|
||||
"index": segment["index"],
|
||||
"start": segment["start"],
|
||||
"end": segment["end"],
|
||||
"total_duration": total_duration,
|
||||
}
|
||||
},
|
||||
}
|
||||
tasks.append(submit_free_video(team=conversation.team, user=user, params=segment_submit))
|
||||
except ValueError as exc: # 校验类错误(时长/比例/额度),给用户看原文
|
||||
return None, str(exc)
|
||||
|
||||
if len(tasks) == 1:
|
||||
message_payload = {"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt}
|
||||
else:
|
||||
message_payload = {
|
||||
"task_id": str(tasks[0].id),
|
||||
"task_ids": [str(task.id) for task in tasks],
|
||||
"kind": "video_segments",
|
||||
"prompt": prompt,
|
||||
"total_duration": total_duration,
|
||||
"segments": [
|
||||
{**segment, "task_id": str(task.id)}
|
||||
for segment, task in zip(segments, tasks, strict=True)
|
||||
],
|
||||
}
|
||||
message = append_message(
|
||||
conversation, role="assistant", kind=CreationMessage.Kind.GENERATING,
|
||||
payload={"task_id": str(task.id), "kind": "video", "prompt": prompt}, task=task,
|
||||
payload=message_payload, task=tasks[0],
|
||||
)
|
||||
_remember_artifact(conversation, prompt, "video")
|
||||
set_video_gate_stage(conversation, "done", clear_pending_prompt=True)
|
||||
@@ -1849,6 +2104,18 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
)
|
||||
stage = get_video_gate_stage(conversation)
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
selling_point = str(memory.get("selling_point") or "").strip()
|
||||
selling_mode = str(memory.get("selling_point_mode") or "").strip()
|
||||
if selling_mode == "manual" and selling_point:
|
||||
lines.append(
|
||||
f"- 商家已确认核心卖点:【{selling_point}】。策略、方案、脚本和出片指令必须围绕它展开;"
|
||||
"只补充可从素材或正常使用中证明的支撑,不得替换或夸大。"
|
||||
)
|
||||
elif selling_mode == "auto":
|
||||
lines.append(
|
||||
"- 商家已授权系统推荐卖点。你必须从商品资料、可见素材和正常使用动作中选择一个最易证明的核心卖点;"
|
||||
"不要虚构功效、价格或规格。"
|
||||
)
|
||||
strategy_confirmed = bool(memory.get("strategy_confirmed"))
|
||||
stage_hint = {
|
||||
"clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了只调 write_strategy。",
|
||||
@@ -2315,6 +2582,29 @@ def iter_creation_agent_events(
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# 明确要商品/角色/场景列表时,直接生成真实选择卡,绝不先让模型念出素材名称。
|
||||
requested_card = requested_asset_card_from_context(conversation, text)
|
||||
if requested_card:
|
||||
result, _stop = _dispatch_tool(
|
||||
context,
|
||||
"ask_user",
|
||||
{"fields": [{
|
||||
"key": requested_card,
|
||||
"label": _ASSET_PICK_LABEL.get(
|
||||
requested_card,
|
||||
_ASSET_CARD_LABELS.get(requested_card, "请选择素材"),
|
||||
),
|
||||
"type": "asset",
|
||||
"required": True,
|
||||
"asset_types": [requested_card],
|
||||
}]},
|
||||
allow_pick=True,
|
||||
)
|
||||
for event in result.get("_events", []):
|
||||
yield event
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or [])
|
||||
context.model_config = model_config
|
||||
|
||||
@@ -2796,6 +3086,14 @@ def _dispatch_tool(
|
||||
return {"payload": _run_search_library(context, args)}, False
|
||||
|
||||
if name == "write_strategy":
|
||||
memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {}
|
||||
if context.is_video and not memory.get("selling_point_ready"):
|
||||
gate = append_selling_point_gate(context.conversation)
|
||||
set_video_gate_stage(context.conversation, "clarify")
|
||||
return {
|
||||
"payload": {"asked": True, "field": "selling_point"},
|
||||
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
||||
}, True
|
||||
strategy_payload = _coerce_strategy_args(args if isinstance(args, dict) else {})
|
||||
# 空卡会落成「只有标签没有正文」——拒绝,让模型把四字段写满再调
|
||||
if not all(strategy_payload.values()):
|
||||
@@ -2849,11 +3147,25 @@ def _dispatch_tool(
|
||||
)
|
||||
}
|
||||
}, False
|
||||
duration = 15
|
||||
try:
|
||||
duration = int(float(str((context.conversation.params or {}).get("duration") or "15").replace("秒", "").strip() or "15"))
|
||||
except (TypeError, ValueError):
|
||||
duration = 15
|
||||
selling_memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {}
|
||||
chosen_selling_point = str(selling_memory.get("selling_point") or "").strip()
|
||||
if chosen_selling_point and str(selling_memory.get("selling_point_mode") or "") == "manual":
|
||||
# 用户亲自给出的真实卖点是本轮的唯一 USP,模型只能围绕它补充画面证据,不能擅自替换。
|
||||
card["usp"] = chosen_selling_point
|
||||
video_prompt = (
|
||||
f"{video_prompt}\n\n【商家确认的核心卖点】{chosen_selling_point}\n"
|
||||
"整条视频必须围绕这个卖点展开,并用真实使用动作或素材可见细节证明;不得替换、夸大或新增未经确认的功效。"
|
||||
)
|
||||
raw_duration = _raw_script_duration(timeline=card["timeline"], prompt=video_prompt)
|
||||
if raw_duration is not None and raw_duration > 60:
|
||||
return {
|
||||
"payload": {"error": "脚本时长不能超过 60 秒。请把方案收束到 60 秒以内,再重新给出完整时间轴和出片指令。"}
|
||||
}, False
|
||||
duration = resolve_smart_video_duration(
|
||||
context.conversation,
|
||||
prompt=video_prompt,
|
||||
timeline=card["timeline"],
|
||||
)
|
||||
lo = max(20, round(duration * 3.4))
|
||||
hi = max(lo + 1, round(duration * 4))
|
||||
plan_payload = {
|
||||
|
||||
Reference in New Issue
Block a user