Files
beige/backend/app/services/ai_engine/storyboard.py
yangqianqian df1856d793 上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段);
  修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品
- dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开;
  settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch
- 新增用户管理(users API/admin service/改密页) + alembic 022/023/024
- 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example
- A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:08:13 +08:00

199 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
storyboard 分镜引擎
扒自worker/src/image.js
- getNarrativeRoles按图数取分镜角色
- proof_strategy按品类定证明页策略品类不写死走数据驱动
- build_visual_system成组视觉统一
- plan_image_set组装最终分镜计划
"""
from __future__ import annotations
import re
from .constants import (
PAGE_ROLE_MAP, IMAGE_NEGATIVE_CONSTRAINTS, IMAGE_XHS_STYLE_CONSTRAINTS,
STYLE_PROMPTS, STYLE_DEFAULT, NARRATIVE_BY_COUNT, NARRATIVE_BY_STRATEGY,
)
# sanitize_text 移至 templates腾行数此处 re-export 供 image_gen 沿用 import
from .storyboard_templates import role_template, proof_strategy, sanitize_text # noqa: F401
def clamp_count(value: int, fallback: int = 6, lo: int = 1, hi: int = 8) -> int:
try:
return max(lo, min(hi, int(value)))
except (TypeError, ValueError):
return fallback
def short_selling_points(points, fallback: str = "") -> str:
"""3个短卖点拼成 a / b / c扒 shortSellingPoints:112-120"""
src = points if isinstance(points, list) else str(points or "").split("")
clean = [sanitize_text(p, 18) for p in src if sanitize_text(p, 18)][:3]
return " / ".join(clean) if clean else sanitize_text(fallback, 28)
def short_tags(tags, keywords=None) -> str:
"""标签去#截断拼成 #a #b扒 shortTags:48-54"""
merged = list(tags or []) + list(keywords or [])
out = []
for t in merged:
c = sanitize_text(str(t), 12).lstrip("#")
if c:
out.append(f"#{c}")
return " ".join(out[:5])
def analyze_copy_for_image(note: dict, product: dict) -> dict:
"""
从文案+产品提取生图锚点(扒 analyzeCopyForImage:129-148
给每张图填 audience/pain/scene/hook让画面有真实代入而非空泛。
"""
text = f"{note.get('title','')}{note.get('coverTitle','')}{note.get('content','')}"
tags = [sanitize_text(str(t).lstrip('#'), 12) for t in (note.get("tags") or [])]
audience = sanitize_text(
product.get("target_audience")
or next((t for t in tags if re.search(r"党|人|妈妈|女生|学生|通勤|上班|办公室", t)), "")
or "目标用户", 18)
scene = sanitize_text(
next((t for t in tags if re.search(r"通勤|宿舍|上课|约会|出门|办公室|旅行|居家|工位", t)), "")
or "日常自然光场景", 18)
pain = sanitize_text(
next((w for w in re.split(r"[、,,。;;!?\n]", text)
if re.search(r"暗沉|没气色|假白|卡粉|搓泥|油|干|赶时间|预算|麻烦", w)), "")
or "日常使用痛点", 18)
hook = sanitize_text(note.get("coverTitle") or note.get("title") or f"{audience}{scene}", 18)
return {"audience": audience, "scene": scene, "pain": pain, "hook": hook}
def get_narrative_roles(image_count: int = 6) -> list[dict]:
"""
按图数返回分镜角色列表(扒 getNarrativeRolesQ6对齐北哥6张套路
≤3 张:极速链路 hook / applied_proof / closer
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分/参数 ④质地/细节 ⑤效果证明(按品类) ⑥促单
>6 张:沉浸链路 + pain_scene / scenario / social_proof
"""
count = clamp_count(image_count)
m = PAGE_ROLE_MAP
if count <= 3:
sequence = ["hook", "applied_proof", "closer"]
elif count <= 6:
# Q6北哥6张标准顺序——品牌词在②(product_closeup)和⑥(closer)两次出现
sequence = ["hook", "product_closeup", "ingredient", "texture", "applied_proof", "closer"]
else:
# 8张沉浸链路在北哥6张基础上插入 pain_scene / social_proof
sequence = ["hook", "pain_scene", "product_closeup", "ingredient", "texture", "applied_proof", "social_proof", "closer"]
return [m[r] for r in sequence[:count] if r in m]
# ── proofStrategy 已移至 storyboard_templates.proof_strategy腾行数超200拆
def build_visual_system(product: dict, analysis: dict | None = None) -> dict:
"""
成组视觉统一(扒 buildVisualSystem
analysis 来自 product.js 分析结果visualIdentity可空
"""
identity = (analysis or {}).get("visualIdentity", {})
palette = (
"".join(identity["colorPalette"][:5])
if isinstance(identity.get("colorPalette"), list) and identity["colorPalette"]
else "提取产品包装主色,搭配浅色真实生活背景"
)
return {
"palette": palette,
"typography": identity.get("typographyStyle", "主标题清晰黑体或手写感标题,辅助文字便签/勾选标注,字重颜色保持同一体系"),
"sticker": identity.get("stickerLanguage", "少量箭头、放大镜、勾选、小表情、便签,不使用促销按钮"),
"layout": identity.get("layoutStyle", "同一组图片保持色调、光线、产品露出方式一致,每张图承担不同叙事角色"),
"texture": identity.get("materialTexture", "产品包装、材质、质地/细节要真实自然,符合本品类的真实使用质感"),
"package_details": identity.get("packageDetails", "如果提供产品图,必须还原包装颜色、瓶身形状、标签方向和主视觉"),
"xhs_style_preset": identity.get("xhsStylePreset", "真实测评风/手写安利风/清单便签风"),
"symbol_system": identity.get("symbolSystem", "中等密度小红书种草符号:✅ ✨ 🌿 💧 🪞 🧴 📦 🔍 💛每张最多2-4个"),
"quality_rules": [
"同组图片字体体系相对一致,但不要像固定模板",
"每张压图文字必须服务当前叙事角色,不能重复封面标题",
"效果证明页按品类出真实使用证据:护肤=手背涂抹/质地微距/自然上脸局部,食品=冲泡入口,家居数码=使用过程,服饰=上身材质,均为真实生活场景",
"人物真实自然有轻微皮肤纹理和生活感不要AI精修美女",
"禁止乱码、错别字、App底栏、Like评论分享、硬广价格牌、虚假功效before/after",
],
}
def plan_image_set(note: dict, product: dict, image_count: int = 3, analysis: dict | None = None, strategy: str | None = None) -> dict:
"""
组装分镜计划(主入口)
strategy: None=默认按图数叙事,'A'/'B'/'C'=三套正交叙事策略
返回:{requested_count, storyboard, visual_system, base_prompt}
storyboard 每项:{role, name, focus, overlay_text, prompt_for_item}
"""
count = clamp_count(image_count)
roles = get_narrative_roles(count)
visual = build_visual_system(product, analysis)
category = product.get("category", "通用好物")
points = product.get("selling_points") or ["核心买点"]
src = analyze_copy_for_image(note, product) # 文案锚点audience/pain/scene/hook
storyboard = []
brand_kw = sanitize_text(product.get("brand_keyword") or "", 12)
brand_roles = {"product_closeup", "closer"} # Q6第2/6张带品牌词
for i, role in enumerate(roles):
point = sanitize_text(points[i % len(points)], 18)
tpl = role_template(role["role"])
proof = proof_strategy(category, point) # 仅 applied_proof 用品类证明
# 填模板占位:每角色画面/文字各不同修缩水根因——不再全角色共用proof
fill = {
"audience": src["audience"], "pain": src["pain"], "scene": src["scene"],
"hook": src["hook"], "point": point, "brand": brand_kw or product.get("name", "产品"),
"proof_overlay": proof.get("overlay", point),
"proof_visual": proof.get("visual", ""),
"proof_forbidden": proof.get("forbidden", ""),
}
item = {
"role": role["role"],
"name": role["name"],
"focus": role["focus"],
"goal": sanitize_text(tpl["goal"].format(**fill), 40),
"overlay_text": sanitize_text(tpl["overlay"].format(**fill), 20),
"visual_strategy": sanitize_text(tpl["visual"].format(**fill), 120),
"source_basis": sanitize_text(tpl["basis"].format(**fill), 60),
"selling_point": point,
"asset_use": proof.get("asset_use", "产品图保证包装准确,参考图用于真实场景"),
"forbidden": sanitize_text(tpl["forbidden"].format(**fill), 60),
}
if brand_kw and role["role"] in brand_roles:
item["brand_keyword"] = brand_kw
item["brand_keyword_rule"] = "品牌词自然融入画面文字或产品露出,不做广告牌感"
storyboard.append(item)
# base_prompt 全局规则(扒 planImageSet basePrompt:682-690
product_name = product.get("name", "产品")
cover_title = sanitize_text(note.get("coverTitle") or note.get("title") or "", 18)
style_text = STYLE_PROMPTS.get(product.get("style_tone") or STYLE_DEFAULT, STYLE_PROMPTS[STYLE_DEFAULT])
# strategy非空时取对应正交叙事否则按图数取默认链路
narrative = NARRATIVE_BY_STRATEGY.get(strategy) if strategy else None
if not narrative:
narrative = NARRATIVE_BY_COUNT.get(count, NARRATIVE_BY_COUNT[6])
sp_text = short_selling_points(points, cover_title)
tag_text = short_tags(note.get("tags") or [], product.get("keywords") or [])
base_prompt = (
f"生成一张小红书可上传的独立3:4图文海报/素材图目标比例1024×1536可直接上传的独立图片不是提示词不是App截图。"
f"产品:{product_name}。短标题:{cover_title}"
f"短卖点:{sp_text}。短标签:{tag_text}"
f"叙事链路:{narrative}"
f"视觉风格:{style_text}"
f"成组视觉:主色={visual['palette']};字体={visual['typography']};贴纸={visual['sticker']}"
f"符号系统={visual['symbol_system']};产品还原={visual['package_details']}"
"重要限制中文文字少而清晰每张只允许一个主标题同一句话禁止重复出现正文点位最多3条"
"四周留安全边距文字不贴边不被裁切真实自然像实拍素材后排版降低AI味"
"禁止生成小红书App界面截图、Like/评论/分享/底栏/头像等社交元素;"
"禁止肤色变白、瑕疵消失、治疗前后等视觉暗示,允许安全的未推开/推开后质地状态对比;"
"如果提供产品图,产品是不可修改的真实商品锚点,禁止改名、换包装、混入其他产品。"
f"\n{IMAGE_NEGATIVE_CONSTRAINTS}"
f"\n{IMAGE_XHS_STYLE_CONSTRAINTS}"
)
return {
"requested_count": count,
"storyboard": storyboard,
"visual_system": visual,
"base_prompt": base_prompt,
}