- 产品编辑入口统一走 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>
199 lines
11 KiB
Python
199 lines
11 KiB
Python
"""
|
||
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]:
|
||
"""
|
||
按图数返回分镜角色列表(扒 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}"
|
||
f"\n{IMAGE_XHS_STYLE_CONSTRAINTS}"
|
||
)
|
||
|
||
return {
|
||
"requested_count": count,
|
||
"storyboard": storyboard,
|
||
"visual_system": visual,
|
||
"base_prompt": base_prompt,
|
||
}
|