大量优化全能创作

This commit is contained in:
Azmat@qq.com
2026-09-16 16:07:56 +08:00
parent a95cd159db
commit 7f5307d97c
9 changed files with 1155 additions and 65 deletions
+193
View File
@@ -6,6 +6,7 @@
from __future__ import annotations
from datetime import timedelta
import uuid
from django.core.cache import cache
from django.db import transaction
@@ -123,9 +124,16 @@ def sync_generating_message(message: CreationMessage) -> bool:
if message.kind != CreationMessage.Kind.GENERATING:
return False
payload = message.payload or {}
if payload.get("kind") == "video_segments":
return _sync_segmented_video_message(message)
task = _message_task(message)
if task is None:
return False
if payload.get("kind") == "video_merge" and task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
# Celery 不在线的本地环境同样可完成用户已确认的合并;此前从不在普通出片完成时调用。
run_segmented_video_merge(str(task.id))
task.refresh_from_db()
if task.status == AITask.Status.SUCCEEDED:
assets = _assets_from_task(task)
if not assets:
@@ -138,6 +146,191 @@ def sync_generating_message(message: CreationMessage) -> bool:
return False
def _sync_segmented_video_message(message: CreationMessage) -> bool:
"""同步全能创作的多段出片。
每段仍是普通 FREE_VIDEO 任务,故可沿用既有计费、轮询和资产落库;这里仅把它们聚合成
一张结果卡。所有分段完成后停在结果卡,绝不在此处触发 ffmpeg。
"""
from .free_video import finalize_free_video
from .models import AITask
payload = message.payload or {}
ids = [str(value) for value in payload.get("task_ids") or [] if value]
if not ids:
return False
by_id = {
str(task.id): task
for task in AITask.objects.filter(id__in=ids).select_related("model_config")
}
tasks = [by_id.get(task_id) for task_id in ids]
if any(task is None for task in tasks):
fail_generating_message(message, "分段视频任务不完整,请重新生成。")
return True
# 本地没有 worker 时,用户的会话轮询本身即可推进每个片段;已在 worker 中的任务则幂等返回。
refreshed = []
for task in tasks:
assert task is not None
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
try:
task = finalize_free_video(task=task)
except Exception: # noqa: BLE001 - 单段网络抖动不能使整组直接失败
task.refresh_from_db()
else:
task.refresh_from_db()
refreshed.append(task)
failed = next(
(task for task in refreshed if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED)),
None,
)
if failed is not None:
number = next((index + 1 for index, task in enumerate(refreshed) if task.id == failed.id), 1)
fail_generating_message(message, f"{number} 段生成失败:{failed.error_message or '请重试'}")
return True
if not all(task.status == AITask.Status.SUCCEEDED for task in refreshed):
return False
assets: list[dict] = []
for index, task in enumerate(refreshed, start=1):
task_assets = _assets_from_task(task)
if not task_assets:
return False
for asset in task_assets:
assets.append({**asset, "label": f"{index}"})
first = refreshed[0]
finish_generating_message(
message,
assets=assets,
meta={
**_meta_from_task(first, message),
"kind": "video_segments",
"segment_count": len(refreshed),
"total_duration": payload.get("total_duration") or "",
"needs_merge": True,
"segments": payload.get("segments") or [],
},
)
return True
def start_segmented_video_merge(*, conversation: CreationConversation, message: CreationMessage, user):
"""用户明确点击后才建立合并任务;这之前绝不下载片段或调用 ffmpeg。"""
from .models import AITask
payload = dict(message.payload or {})
if message.kind != CreationMessage.Kind.RESULT or not payload.get("needs_merge"):
raise ValueError("这条结果不需要合并")
state = str(payload.get("merge_state") or "")
if state in {"queued", "processing", "completed"}:
raise ValueError("这组片段已经在合并中或已合并")
task_ids = [str(value) for value in payload.get("task_ids") or [] if value]
source_tasks = list(AITask.objects.filter(id__in=task_ids).select_related("model_config"))
if len(source_tasks) != len(task_ids):
raise ValueError("分段视频不完整,无法合并")
model_config = source_tasks[0].model_config
merge_task = AITask.objects.create(
team=conversation.team,
created_by=user,
project=None,
task_type=AITask.Type.EXPORT,
status=AITask.Status.SUBMITTED,
model_config=model_config,
idempotency_key=f"omni-video-merge:{conversation.id}:{message.id}:{uuid.uuid4()}",
request_payload={
"feature": "omni_video_merge",
"source_message_id": str(message.id),
"source_task_ids": task_ids,
"prompt": payload.get("prompt") or "",
},
)
payload["merge_state"] = "queued"
payload["merge_task_id"] = str(merge_task.id)
message.payload = payload
message.save(update_fields=["payload", "updated_at"])
generating = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.GENERATING,
payload={"task_id": str(merge_task.id), "kind": "video_merge", "prompt": payload.get("prompt") or ""},
task=merge_task,
)
return merge_task, generating
def run_segmented_video_merge(task_id: str) -> None:
"""后台执行用户已确认的合并。任务创建不等于执行;仅此函数调用 ffmpeg。"""
from .free_video import _store_free_video_media
from .models import AITask
from .services import asset_stable_url
from .video_replace import _concat_shot_media
from apps.assets.models import Asset
task = AITask.objects.select_related("team", "created_by").filter(id=task_id).first()
if task is None or task.task_type != AITask.Type.EXPORT:
return
source_message_id = str((task.request_payload or {}).get("source_message_id") or "")
source_message = CreationMessage.objects.filter(id=source_message_id).first()
if source_message is None:
return
try:
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=task.id)
if locked.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
return
locked.status = AITask.Status.POSTPROCESSING
locked.save(update_fields=["status", "updated_at"])
urls: list[str] = []
for source_id in (locked.request_payload or {}).get("source_task_ids") or []:
source_asset = (
Asset.objects.filter(origin_task_id=source_id, is_deleted=False, purged_at__isnull=True)
.prefetch_related("files")
.first()
)
url, _cover = asset_stable_url(source_asset)
if not url:
raise ValueError("有片段尚未准备好,暂时不能合并")
urls.append(url)
merged_bytes = _concat_shot_media(urls)
_store_free_video_media(task=locked, video_bytes=merged_bytes)
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=task.id)
if locked.status != AITask.Status.POSTPROCESSING:
return
locked.status = AITask.Status.SUCCEEDED
locked.completed_at = timezone.now()
locked.save(update_fields=["status", "completed_at", "updated_at"])
source = CreationMessage.objects.select_for_update().get(id=source_message.id)
source_payload = dict(source.payload or {})
source_payload["merge_state"] = "completed"
source.payload = source_payload
source.save(update_fields=["payload", "updated_at"])
except Exception as exc: # noqa: BLE001 - 合并失败不影响已完成的分段视频
import logging
logging.getLogger(__name__).exception("omni video merge failed for %s", task_id)
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=task.id)
if locked.status == AITask.Status.POSTPROCESSING:
locked.status = AITask.Status.FAILED
locked.error_code = "MergeError"
locked.error_message = str(exc)[:500]
locked.completed_at = timezone.now()
locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"])
source = CreationMessage.objects.select_for_update().filter(id=source_message.id).first()
if source is not None:
source_payload = dict(source.payload or {})
source_payload["merge_state"] = "failed"
source.payload = source_payload
source.save(update_fields=["payload", "updated_at"])
finally:
# 复用既有「生成中 → 结果/错误」转换,生成的合成片会成为独立结果卡。
fresh = AITask.objects.filter(id=task_id).first()
if fresh is not None:
sync_generating_for_task(fresh)
def sync_generating_messages(conversation: CreationConversation) -> int:
"""把一条会话里所有已结束的 GENERATING 回填。返回改了几条。"""
pending = list(
+325 -13
View File
@@ -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 = {
+2 -2
View File
@@ -867,7 +867,7 @@ def _store_free_video_media(*, task: AITask, media: str = "", video_bytes: bytes
if feature == "video_replace":
mode_label = "角色复刻" if payload.get("replace_mode") == "character" else "商品复刻"
asset_name = f"{subject}{mode_label}" if subject else mode_label
elif feature == "omni_create":
elif feature in {"omni_create", "omni_video_merge"}:
asset_name = prompt[:255] or "全能创作视频"
else:
asset_name = prompt[:255] or "自由创作视频"
@@ -883,7 +883,7 @@ def _store_free_video_media(*, task: AITask, media: str = "", video_bytes: bytes
category=Asset.Category.FREE_CREATE,
origin_task=task,
# 全能创作成品只挂在会话结果卡上,不进资产库/专业创作用的视频列表。
in_library=feature != "omni_create",
in_library=feature not in {"omni_create", "omni_video_merge"},
metadata={"feature": feature},
)
AssetFile.objects.create(
+9
View File
@@ -121,6 +121,15 @@ def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str:
return task_id
@app.task(bind=True, max_retries=0)
def merge_omni_video_segments_task(self, task_id: str) -> str:
"""全能创作的分段视频仅在用户点「合并成片」后进入此任务。"""
from apps.ai.creation import run_segmented_video_merge
run_segmented_video_merge(task_id)
return task_id
@app.task(bind=True, max_retries=0, queue=QUICK_QUEUE)
def drain_asset_reviews_task(self) -> str:
"""全平台人脸审核兜底:未送审自动送火山、审核中自动拉绿/红盾。
@@ -34,6 +34,8 @@ from .creation_agent import (
get_creation_chat_model,
get_video_gate_stage,
has_creative_intent,
infer_script_duration,
plan_video_segments,
is_continue_intent,
is_greeting,
is_pure_chitchat,
@@ -943,6 +945,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertNotIn("generate_image", names)
def test_strategy_card_stops_with_step_confirm(self):
self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"}
self.conversation.save(update_fields=["memory", "updated_at"])
fake = FakeProvider([
_tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈",
"belief": "值得一试", "direction": "达人 UGC 口播"}),
@@ -964,6 +968,21 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
# 策略闸门必须停下等人确认,不能同轮连写方案
self.assertEqual(len(fake.calls), 1)
def test_selling_point_is_confirmed_before_strategy(self):
fake = FakeProvider([
_tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈",
"belief": "值得一试", "direction": "达人 UGC 口播"}),
])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(conversation=self.conversation, user=self.user,
text="做条视频", model_config=self.model))
cards = [
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
]
self.assertEqual(cards[-1]["payload"].get("interaction"), "selling_point_gate")
self.assertFalse(any(event.get("message", {}).get("kind") == "strategy" for event in events))
def test_plan_emits_plan_and_step_confirm_only(self):
fake = FakeProvider([
_tool_chunks("write_plan", self._plan_args()),
@@ -991,6 +1010,23 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
# 方案闸门停下,不能同轮出 Prompt/积分卡
self.assertEqual(len(fake.calls), 1)
def test_manual_selling_point_is_locked_as_plan_usp(self):
self.conversation.memory = {
"selling_point_ready": True,
"selling_point_mode": "manual",
"selling_point": "一喷一擦去除厨房重油污",
}
self.conversation.save(update_fields=["memory", "updated_at"])
fake = FakeProvider([_tool_chunks("write_plan", self._plan_args())])
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))
plan = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "plan"
)
self.assertEqual(plan["payload"]["usp"], "一喷一擦去除厨房重油污")
def test_write_prompt_emits_document_and_requires_prompt_confirmation(self):
fake = FakeProvider([
_tool_chunks("write_prompt", {"video_prompt": "0-3秒 近景手持商品…"}),
@@ -1015,6 +1051,9 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
"""模型若同轮连调 write_strategy+write_plan,只落策略闸门。"""
from apps.ai.creation_agent import _parse_arguments # noqa: F401
self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"}
self.conversation.save(update_fields=["memory", "updated_at"])
# FakeProvider 一轮里多个 tool_call:模拟两个独立 rounds 不行,需单轮多 call。
# 用手动合并:第一帧带两个 tool call deltas。
def _multi_tool_chunks(*pairs):
@@ -1174,6 +1213,27 @@ class VideoParamParsingTests(TestCase):
# 当前视频工作流支持完整短剧,超出预设上限时仍需夹住。
self.assertEqual(video_duration({"duration": "99 秒"}), 60)
def test_smart_duration_uses_final_plan_timestamp(self):
timeline = [
{"start": 0, "end": 2, "stage": "Hook"},
{"start": 2, "end": 14, "stage": "正文"},
{"start": 14, "end": 18, "stage": "证据"},
{"start": 18, "end": 20, "stage": "CTA"},
]
self.assertEqual(infer_script_duration(timeline=timeline), 20)
self.assertEqual(video_duration({"duration": "智能时长"}, timeline=timeline), 20)
def test_long_video_is_split_into_seedance_sized_segments(self):
self.assertEqual(
plan_video_segments(45, [{"end": 14}, {"end": 25}, {"end": 45}]),
[
{"index": 1, "start": 0, "end": 25, "duration": 25},
{"index": 2, "start": 25, "end": 45, "duration": 20},
],
)
self.assertEqual(plan_video_segments(60)[0]["duration"], 30)
self.assertEqual(plan_video_segments(60)[1]["duration"], 30)
def test_model_label_maps_to_volcano_name(self):
self.assertEqual(video_model_name({"model": "Seedance 2.0 Fast"}), "doubao-seedance-2-0-fast-260128")
self.assertEqual(video_model_name({"model": "没见过的模型"}), DEFAULT_VIDEO_MODEL)
+102
View File
@@ -30,6 +30,7 @@ from .creation import (
cleanup_stale_agent_planning,
finish_agent_planning,
request_agent_cancel,
start_segmented_video_merge,
sync_generating_messages,
team_agent_busy,
)
@@ -139,6 +140,11 @@ _WANTS_CARD_RE = re.compile(
re.IGNORECASE,
)
_WANTS_PRODUCT_PICKER_RE = re.compile(
r"(?:商品|产品).{0,8}(?:列表|库)|(?:列表|库).{0,8}(?:商品|产品)",
re.IGNORECASE,
)
# 步骤确认卡下,用户往往不会去点「按这个继续」,而是自然地回一句
# 「这个还行」「就这样」「没问题」。这些是确认,不应被误判成修改意见。
_STEP_TEXT_CONFIRM_RE = re.compile(
@@ -169,6 +175,50 @@ def _pending_chat_question(conversation: CreationConversation) -> CreationMessag
return None
def _open_product_picker(
conversation: CreationConversation,
user_text: str,
pending: CreationMessage | None = None,
):
"""用户要求商品列表时,直接返回可点击的商品卡。"""
if pending is not None and not bool((pending.payload or {}).get("submitted")):
payload = dict(pending.payload or {})
payload.update({"submitted": True, "superseded_by": "product_picker"})
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
user_message = append_message(conversation, role="user", text=user_text)
field = {
"key": "product",
"label": _ASSET_CARD_LABELS.get("product", "选择要推广的商品"),
"type": "asset",
"required": True,
"asset_types": ["product"],
}
picker = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=field["label"],
payload={
"interaction": "asset_picker",
"phase": "pick",
"fields": [field],
"submitted": False,
"answers": {},
},
)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [
CreationMessageSerializer(user_message).data,
CreationMessageSerializer(picker).data,
],
}, status=200)
def _plot_twist_depth_continuation(depth: dict) -> str:
"""让故事深度卡的回答直接进入对应的创作分支。"""
if depth.get("value") == "smart":
@@ -1694,6 +1744,28 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
raise ValidationError({"detail": "after_seq 必须是整数"}) from exc
return Response(CreationMessageSerializer(queryset, many=True).data)
@action(detail=True, methods=["post"], url_path="merge-video-segments")
def merge_video_segments(self, request, pk=None):
"""用户确认后才合并多段成片;未点击前不下载视频、更不调用 ffmpeg。"""
conversation = self.get_object()
message_id = str(request.data.get("message_id") or "").strip()
message = conversation.messages.filter(id=message_id).first() if message_id else None
if message is None:
return JsonResponse({"detail": "找不到要合并的视频片段"}, status=404)
try:
merge_task, generating = start_segmented_video_merge(
conversation=conversation, message=message, user=request.user
)
except ValueError as exc:
return JsonResponse({"detail": str(exc)}, status=400)
try:
from .tasks import merge_omni_video_segments_task
merge_omni_video_segments_task.apply_async(args=[str(merge_task.id)])
except Exception: # noqa: BLE001 - 没有 worker 时前端轮询仍可看到任务,避免重复执行 ffmpeg
logger.warning("omni video merge enqueue failed for %s", merge_task.id, exc_info=True)
return JsonResponse({"message": CreationMessageSerializer(generating).data}, status=202)
@action(detail=True, methods=["post"], url_path="cancel")
def cancel(self, request, pk=None):
"""终止进行中的整理方案(agent planning)。
@@ -1765,6 +1837,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
# 打招呼不应被误当成上一条素材追问的答案;让 agent 立刻简短回应即可。
elif kind == "text" and text and not is_greeting(text):
pending = _pending_chat_question(conversation)
# 无论旧会话遗留了什么追问,只要用户要商品列表,就直接出可点击卡片。
# 不能再交给模型 search_library 后用文字念出商品名。
if _WANTS_PRODUCT_PICKER_RE.search(text):
return _open_product_picker(conversation, text, pending)
if pending is not None:
payload = dict(pending.payload or {})
if payload.get("interaction") == "step_confirm":
@@ -2049,6 +2125,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
record_user_message = False
# force_creative / continuation 已设;跳过后面普通 elicit 逻辑
else:
if payload.get("interaction") == "selling_point_gate":
mode = str(answers.get("selling_point_mode") or "").strip().lower()
selling_point = str(answers.get("selling_point") or "").strip()
if mode not in {"manual", "auto"}:
return JsonResponse({"detail": "请选择自己填写卖点或系统推荐"}, status=400)
if mode == "manual" and not selling_point:
return JsonResponse({"detail": "请先填写一个真实卖点,或选择系统推荐"}, status=400)
payload["answers"] = answers
payload["submitted"] = True
card.payload = payload
@@ -2078,6 +2161,25 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
continuation_instruction = _plot_twist_direction_continuation(
conversation, payload, choice
)
elif payload.get("interaction") == "selling_point_gate":
mode = str(answers.get("selling_point_mode") or "").strip().lower()
selling_point = str(answers.get("selling_point") or "").strip()
memory = dict(conversation.memory or {})
memory["selling_point_ready"] = True
memory["selling_point_mode"] = mode
memory["selling_point"] = selling_point if mode == "manual" else ""
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = (
f"商家已确认核心卖点:【{selling_point}】。现在只调用 write_strategy 写创作策略,"
"再等待商家确认;策略、后续方案和视频都必须围绕这个真实卖点,不要替换或夸大。"
if mode == "manual"
else "商家选择系统推荐卖点。现在只调用 write_strategy 写创作策略;"
"从商品资料和现有素材中挑一个最容易被画面证明的真实核心卖点,不要虚构功效、价格或规格。"
)
elif payload.get("phase") == "gate":
choice = str(answers.get("_asset_gate") or "").strip()
if choice == "send":