""" 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, }