优化脚本

This commit is contained in:
Azmat@qq.com
2026-08-25 16:40:22 +08:00
parent e2ec2d14af
commit df6784b90c
22 changed files with 893 additions and 156 deletions
+104 -7
View File
@@ -102,6 +102,11 @@ _SHOT_SIZE_MARKERS = (
"特写", "近景", "中近景", "中景", "全景", "远景", "胸上", "过肩",
"手持", "跟拍", "俯拍", "仰拍", "推近", "拉远", "",
)
_MINOR_CHARACTER_RE = re.compile(
r"(?:婴儿|宝宝|宝贝|幼儿|儿童|小孩|小朋友|未成年|男童|女童|baby|toddler|infant)"
r"|(?:[0-9零一二三四五六七八九十]{1,3}\s*岁)",
re.IGNORECASE,
)
_FORMAT_KEY_BY_LABEL = {label: key for key, label in PRESENTATION_FORMATS.items()}
@@ -224,6 +229,30 @@ def format_visual_beats(beats: list[tuple[int, int, str]]) -> str:
return "\n".join(f"{start}-{end}s{body}" for start, end, body in beats)
def _is_minor_character(name: str, visual_prompt: str) -> bool:
"""角色基础资产不得是未成年人,避免真人生图审核拦截与儿童肖像风险。"""
return bool(_MINOR_CHARACTER_RE.search(f"{name or ''} {visual_prompt or ''}"))
def _product_only_visual(duration: int) -> str:
"""模型误建未成年人角色时,后端降级为无人物的商品导演说明,绝不下传儿童画面。"""
end = max(4, min(SEGMENT_DURATION_MAX, int(duration or SEGMENT_DURATION_MAX)))
if end >= 12:
beats = [
(0, 3, "商品包装与整体外观特写;俯拍;缓慢推近;商品平放在干净展示台;先看清真实配色与轮廓。"),
(3, 8, "商品关键材质与细节近景;固定机位;微距横移;镜头逐项扫过结构和图案;信息从整体转到细节。"),
(8, 12, "商品使用相关部位特写;45度侧拍;缓慢推近;只展示商品本身与必要道具;强调真实做工。"),
(12, end, "商品完整陈列中景;平视;轻微拉远;干净背景中保留商品主体;画面收束到购买信息。"),
]
else:
mid = max(1, end // 2)
beats = [
(0, mid, "商品整体外观特写;俯拍;缓慢推近;商品平放在干净展示台;看清真实配色与轮廓。"),
(mid, end, "商品细节近景;45度侧拍;微距横移;只展示商品本身;画面收束到关键结构。"),
]
return "【本镜任务】用商品本身传达关键信息,不出现人物。\n【声音】旁白继续,画面不出现未成年人。\n【画面内容】\n" + format_visual_beats(beats)
# --------------------------------------------------------------------------- #
# skill 加载(缓存)
# --------------------------------------------------------------------------- #
@@ -452,6 +481,49 @@ def _product_context(project, selling_point_ids: list[str] | None, persona: str
)
def _script_product_reference_urls(project, model_config: ModelConfig | None = None) -> list[str]:
"""脚本模型可见的商品实拍图,最多三张。
标题和卖点通常没有颜色、外形等视觉事实。若脚本先写错,后面的视频模型就会收到
相互矛盾的指令。Seed 2.1 Pro 原生支持图文消息;其他模型须在后台显式声明视觉能力,
避免把图片发给纯文本模型而导致脚本生成失败。
"""
if model_config is None:
return []
metadata = model_config.metadata if isinstance(model_config.metadata, dict) else {}
capabilities = metadata.get("capabilities") if isinstance(metadata.get("capabilities"), dict) else {}
features = {str(item) for item in capabilities.get("features") or []}
known_seed_vision = str(model_config.name or "").startswith("doubao-seed-2-1-pro-")
if not (known_seed_vision or {"vision", "image_input", "multimodal"} & features):
return []
# 与图片/视频链路一致:真实上传图优先、主图优先,最多三张。
from apps.ai.services import _product_reference_urls
return _product_reference_urls(project.product, limit=3)
def _append_product_visual_references(messages: list[dict], image_urls: list[str]) -> list[dict]:
"""将用户选择的真实商品图附到任务消息,并声明其为外观事实的最高优先级。"""
if not image_urls or not messages:
return messages
result = [dict(message) for message in messages]
last = dict(result[-1])
text = str(last.get("content") or "")
content: list[dict] = [{
"type": "text",
"text": (
f"{text}\n\n【商品视觉参考·最高优先级】以下 {len(image_urls)} 张图片是用户选择的真实商品图。"
"颜色、外形、材质、结构、配件与可见品牌标识必须以图片为准;若文字资料与图片不一致,以图片为准。"
"旁白、商品实体 visual_prompt 和每镜 visual 不得臆测或改写这些外观事实;"
"尤其不得把深色商品写成白色或浅色商品。"
),
}]
content.extend({"type": "image_url", "image_url": {"url": url}} for url in image_urls)
last["content"] = content
result[-1] = last
return result
def build_agent_messages(
*,
project,
@@ -465,7 +537,8 @@ def build_agent_messages(
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
target_index: int | None = None,
persona: str | None = None,
) -> list[dict[str, str]]:
product_image_urls: list[str] | None = None,
) -> list[dict]:
fmt, structure = coerce_combo(presentation_format, video_structure)
if target_index is not None:
try:
@@ -603,7 +676,10 @@ def build_agent_messages(
f"{extra_block}\n"
"请按技能流程一次性产出 ScriptDraft。"
)
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
return _append_product_visual_references(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
product_image_urls or [],
)
# --------------------------------------------------------------------------- #
@@ -1045,10 +1121,12 @@ def normalize_draft(
draft["tone"] = tone if tone in VALID_TONES else "种草"
draft["hook"] = (draft.get("hook") or "").strip()
# entities 规范化:补 id / ref_index,过滤非法 type
# entities 规范化:补 id / ref_index,过滤非法 type。未成年人不能作为角色基础资产生成,
# 否则会触发真人生图审核,也不应把儿童肖像交给后续分镜/视频链路。
entities = draft.get("entities") if isinstance(draft.get("entities"), list) else []
norm_entities: list[dict] = []
seen_ids: set[str] = set()
minor_character_ids: set[str] = set()
for i, ent in enumerate(entities):
if not isinstance(ent, dict):
continue
@@ -1059,12 +1137,17 @@ def normalize_draft(
etype = (ent.get("type") or "").strip()
if etype not in VALID_ENTITY_TYPES:
etype = "character"
name = (ent.get("name") or eid).strip()
visual_prompt = (ent.get("visual_prompt") or "").strip()
if etype == "character" and _is_minor_character(name, visual_prompt):
minor_character_ids.add(eid)
continue
norm_entities.append(
{
"id": eid,
"type": etype,
"name": (ent.get("name") or eid).strip(),
"visual_prompt": (ent.get("visual_prompt") or "").strip(),
"name": name,
"visual_prompt": visual_prompt,
"ref_index": ent.get("ref_index") if isinstance(ent.get("ref_index"), int) else i + 1,
"voice_ref": ent.get("voice_ref") or None,
}
@@ -1085,15 +1168,21 @@ def normalize_draft(
seg_count = expected
role_plan = plan_roles(seg_count)
norm_segments: list[dict] = []
segments_with_minor_reference: set[int] = set()
for i, seg in enumerate(segments):
if not isinstance(seg, dict):
seg = {}
role = (seg.get("role") or "").strip()
if role not in VALID_ROLES:
role = role_plan[i]
raw_refs = seg.get("entity_refs") or []
if not isinstance(raw_refs, list):
raw_refs = []
speaker = seg.get("speaker")
if speaker in minor_character_ids or any(ref in minor_character_ids for ref in raw_refs):
segments_with_minor_reference.add(i)
speaker = speaker if (speaker in valid_ids) else None
refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids]
refs = [r for r in raw_refs if r in valid_ids]
# 对白(剧情向):[{speaker(合法 entity id 或 null=旁白), line}];默认空 = 纯口播。
# 模型变体的口播字段五花八门:dialogue(字符串/数组)/ lines(数组)/ 每项 line|text|content。
# 这里把任意数组形态归一成结构化对白,字符串形态留给下面当整句旁白。
@@ -1161,7 +1250,9 @@ def normalize_draft(
draft["total_duration"] = sum(fitted)
for index, (norm, seconds) in enumerate(zip(norm_segments, fitted)):
norm["duration"] = seconds
if index < len(segments) and isinstance(segments[index], dict):
if index in segments_with_minor_reference:
norm["visual"] = _product_only_visual(seconds)
elif index < len(segments) and isinstance(segments[index], dict):
composed = compose_segment_visual(segments[index], seconds)
if composed:
norm["visual"] = composed
@@ -1373,6 +1464,7 @@ def stream_script_agent(
product, selling_points = _product_facts(project, selling_point_ids)
selling_titles = [sp.title for sp in selling_points]
layout = _preserve_layout_from(base_draft) if target_index is not None else None
product_image_urls = _script_product_reference_urls(project, model_config)
messages = build_agent_messages(
project=project,
mode=mode,
@@ -1385,6 +1477,7 @@ def stream_script_agent(
video_structure=structure,
target_index=target_index,
persona=persona,
product_image_urls=product_image_urls,
)
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
@@ -1403,6 +1496,7 @@ def stream_script_agent(
"total_duration": total_duration,
"base_version_id": str(base_version_id or ""),
"target_index": target_index,
"product_image_references": len(product_image_urls),
"model_routing_v1": True,
},
)
@@ -1668,6 +1762,7 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
# 改一镜要沿用原稿的套路,否则重写出来的那一镜镜头语言会跟其余镜打架
fmt, structure = combo_keys(base_draft.get("presentation_format"), base_draft.get("video_structure"))
product_image_urls = _script_product_reference_urls(project, model_config)
messages = build_agent_messages(
project=project,
mode="revise",
@@ -1680,6 +1775,7 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
video_structure=structure,
target_index=target_index,
persona=_resolve_persona(project, None),
product_image_urls=product_image_urls,
)
task = create_ai_task(
project=project,
@@ -1691,6 +1787,7 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
"endpoint": model_config.endpoint,
"mode": "revise",
"target_index": target_index,
"product_image_references": len(product_image_urls),
"model_routing_v1": True,
},
)