baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
197
backend/app/services/ai_engine/storyboard.py
Normal file
197
backend/app/services/ai_engine/storyboard.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
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,
|
||||
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]:
|
||||
"""
|
||||
按图数返回分镜角色列表(扒 getNarrativeRoles,Q6对齐北哥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}"
|
||||
)
|
||||
|
||||
return {
|
||||
"requested_count": count,
|
||||
"storyboard": storyboard,
|
||||
"visual_system": visual,
|
||||
"base_prompt": base_prompt,
|
||||
}
|
||||
Reference in New Issue
Block a user