优化脚本
This commit is contained in:
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1957,7 +1957,15 @@ def build_person_frontal_prompt(description: str = "") -> str:
|
||||
desc = (description or "").strip()
|
||||
# 正文可在 admin「提示词」页改;{描述}=脚本提取/用户输入的人物描述(写死字段)。默认含原质量尾串。
|
||||
default = "电商真人模特,氛围正面全身照,{描述},自然妆容,柔和影棚光,真实质感,单人,纯色背景"
|
||||
return render_prompt("person_portrait", default, 描述=desc)
|
||||
rendered = render_prompt("person_portrait", default, 描述=desc)
|
||||
# 人物基础资产是给后续故事板锁脸用的「角色参考」,绝不能提前把商品塞进画面。
|
||||
# 否则图像模型会自行重绘包装,污染唯一可信的商品参考图。即使后台模板或脚本描述带了商品,
|
||||
# 这个约束也必须最终覆盖它;商品只允许由 product 基础资产和故事板/视频阶段传入。
|
||||
return (
|
||||
f"{rendered}。硬性约束:这是单人角色参考图,不是带货海报;"
|
||||
"画面中绝对不要出现任何商品、产品包装、品牌文字、Logo、价签、桌子、电脑、杯子、手持物或生活场景。"
|
||||
"只保留一位人物和干净纯色背景。"
|
||||
)
|
||||
|
||||
|
||||
def build_person_portrait_prompt_refs(description: str = "") -> str:
|
||||
@@ -1967,6 +1975,7 @@ def build_person_portrait_prompt_refs(description: str = "") -> str:
|
||||
base = (
|
||||
"参考图是该角色当前的立绘。保持参考图中人物的相貌、五官、发型、肤色与身份特征完全一致(同一个人),"
|
||||
"重绘为电商真人模特氛围正面全身照,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"
|
||||
"硬性约束:仅保留人物,绝对不要出现商品、产品包装、品牌文字、Logo、价签、桌子、电脑、杯子、手持物或生活场景。"
|
||||
)
|
||||
if desc:
|
||||
base += f"在保持人物一致的前提下,按以下要求调整:{desc}。"
|
||||
|
||||
@@ -139,6 +139,26 @@ class NormalizeDurationTests(SimpleTestCase):
|
||||
self.assertEqual([s["duration"] for s in draft["segments"]], [12, 8, 10])
|
||||
self.assertEqual(draft["total_duration"], 30)
|
||||
|
||||
def test_minor_character_is_removed_and_its_shot_becomes_product_only(self):
|
||||
import json
|
||||
|
||||
raw = {
|
||||
"entities": [
|
||||
{"id": "baby", "type": "character", "name": "1岁宝宝", "visual_prompt": "软萌婴儿坐在爬行垫上"},
|
||||
{"id": "scene", "type": "scene", "name": "展示台", "visual_prompt": "干净展示台"},
|
||||
{"id": "product", "type": "product", "name": "连体爬服", "visual_prompt": "奶白色连体爬服"},
|
||||
],
|
||||
"segments": [{
|
||||
"role": "钩子", "narration": "适合一岁宝宝的连体爬服。", "visual": "宝宝坐在爬行垫上晃动手脚。",
|
||||
"entity_refs": ["baby", "scene", "product"],
|
||||
}],
|
||||
}
|
||||
draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15)
|
||||
self.assertNotIn("baby", [entity["id"] for entity in draft["entities"]])
|
||||
self.assertEqual(draft["segments"][0]["entity_refs"], ["scene", "product"])
|
||||
self.assertIn("不出现人物", draft["segments"][0]["visual"])
|
||||
self.assertNotIn("宝宝坐", draft["segments"][0]["visual"])
|
||||
|
||||
|
||||
class ProductFactTests(SimpleTestCase):
|
||||
def test_missing_selling_point_is_rejected(self):
|
||||
@@ -304,6 +324,25 @@ class PromptAssemblyTests(SimpleTestCase):
|
||||
self.assertIn("一句话主题扩写", messages[1]["content"])
|
||||
self.assertIn("主打熬夜党", messages[1]["content"])
|
||||
|
||||
def test_product_images_become_visual_truth_for_script(self):
|
||||
messages = build_agent_messages(
|
||||
project=self._project(),
|
||||
mode="auto",
|
||||
user_prompt="",
|
||||
selling_point_ids=None,
|
||||
base_draft=None,
|
||||
aspect_ratio="9:16",
|
||||
total_duration=15,
|
||||
product_image_urls=["https://example.test/black-earbuds.png", "https://example.test/case.png"],
|
||||
)
|
||||
content = messages[1]["content"]
|
||||
self.assertIsInstance(content, list)
|
||||
self.assertIn("商品视觉参考·最高优先级", content[0]["text"])
|
||||
self.assertIn("不得把深色商品写成白色", content[0]["text"])
|
||||
self.assertEqual([item["image_url"]["url"] for item in content[1:]], [
|
||||
"https://example.test/black-earbuds.png", "https://example.test/case.png",
|
||||
])
|
||||
|
||||
|
||||
class NarrationLimitTests(SimpleTestCase):
|
||||
def test_limit_scales_with_shot_length(self):
|
||||
|
||||
Reference in New Issue
Block a user