#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 智能海报生成引擎 V2 输出: 1. preview_base64 - 无底图预览 PNG 2. fabric_json - Fabric.js 编辑用 JSON """ import json import os import base64 import io from typing import Optional, Dict, Any, List from .base import BaseAIGCEngine as BaseEngine, EngineResult from poster_v2 import PosterFactory, PosterContent from poster_v2.schemas.theme import THEMES, Theme class PosterSmartEngineV2(BaseEngine): """智能海报生成引擎 V2 - 双输出 (预览PNG + Fabric JSON)""" name = "poster_smart_v2" description = "智能海报生成 (AI文案 + 预览PNG + Fabric JSON)" version = "2.0.0" # 画布尺寸 CANVAS_WIDTH = 1080 CANVAS_HEIGHT = 1440 # 类型到布局的映射 CATEGORY_LAYOUT_MAP = { "景点": "hero_bottom", "美食": "overlay_bottom", "酒店": "card_float", "民宿": "split_vertical", "活动": "overlay_center", "攻略": "hero_bottom", } # 类型到主题的映射 CATEGORY_THEME_MAP = { "景点": "ocean", "美食": "peach", "酒店": "ocean", "民宿": "latte", "活动": "sunset", "攻略": "mint", } def __init__(self): super().__init__() self._ai_agent = None self._poster_factory = None def get_param_schema(self) -> dict: """返回参数 schema""" return { "type": "object", "properties": { "category": {"type": "string", "description": "类型 (景点/美食/酒店/民宿/活动/攻略)"}, "name": {"type": "string", "description": "名称"}, "description": {"type": "string", "description": "描述"}, "price": {"type": "string", "description": "价格"}, "location": {"type": "string", "description": "地点"}, "features": {"type": "string", "description": "特色/卖点,逗号分隔"}, "image_url": {"type": "string", "description": "图片 URL"}, "override_layout": {"type": "string", "description": "强制布局"}, "override_theme": {"type": "string", "description": "强制主题"}, "skip_ai": {"type": "boolean", "description": "跳过 AI 生成"}, }, "required": ["category", "name"] } async def execute(self, params: dict) -> EngineResult: """执行引擎""" try: # 1. 提取参数 category = params.get("category", "景点") name = params.get("name", "") description = params.get("description", "") price = params.get("price", "") location = params.get("location", "") features = params.get("features", "") image_url = params.get("image_url", "") # 覆盖布局/主题 override_layout = params.get("override_layout") override_theme = params.get("override_theme") skip_ai = params.get("skip_ai", False) # 2. 生成文案 (AI 或 备用) if skip_ai: content = self._fallback_content(params) else: content = await self._generate_ai_content(params) # 3. 确定布局和主题 layout = override_layout or content.get("suggested_layout") or self.CATEGORY_LAYOUT_MAP.get(category, "hero_bottom") theme_name = override_theme or content.get("suggested_theme") or self.CATEGORY_THEME_MAP.get(category, "sunset") # 验证布局和主题 valid_layouts = ["hero_bottom", "overlay_center", "overlay_bottom", "split_vertical", "card_float"] if layout not in valid_layouts: layout = "hero_bottom" if theme_name not in THEMES: theme_name = "sunset" theme = THEMES[theme_name] # 4. 生成预览 PNG 和 Fabric 对象 preview_base64, fabric_objects = self._generate_preview(content, layout, theme, image_url) # 5. 构建 Fabric JSON fabric_json = { "version": "5.3.0", "canvas": { "width": self.CANVAS_WIDTH, "height": self.CANVAS_HEIGHT, "backgroundColor": theme.secondary, }, "layout": layout, "theme": theme.name, "objects": fabric_objects if fabric_objects else self._generate_fallback_objects(content, layout, theme, image_url), } return EngineResult( success=True, data={ "preview_base64": preview_base64, "fabric_json": fabric_json, "layout": layout, "theme": theme_name, "content": content } ) except Exception as e: self.log(f"执行失败: {e}", level='error') import traceback traceback.print_exc() return EngineResult(success=False, error=str(e)) async def _generate_ai_content(self, params: dict) -> dict: """AI 生成文案""" try: ai_agent = self._get_ai_agent() if not ai_agent: return self._fallback_content(params) # 加载 prompt from domain.prompt import PromptRegistry registry = PromptRegistry("prompts") prompt_config = registry.get("poster_copywriting") if not prompt_config: return self._fallback_content(params) # 构建用户提示 category = params.get("category", "景点") name = params.get("name", "") description = params.get("description", "") price = params.get("price", "") location = params.get("location", "") features = params.get("features", "") target_audience = params.get("target_audience", "") style_hint = params.get("style_hint", "") user_prompt = prompt_config.user user_prompt = user_prompt.replace("{category}", category) user_prompt = user_prompt.replace("{name}", name) user_prompt = user_prompt.replace("{description}", description or "无详细描述") user_prompt = user_prompt.replace("{price}", price or "") user_prompt = user_prompt.replace("{location}", location or "") user_prompt = user_prompt.replace("{features}", features or "") user_prompt = user_prompt.replace("{target_audience}", target_audience or "") user_prompt = user_prompt.replace("{style_hint}", style_hint or "") # 调用 AI content_text, _, _, _ = await ai_agent.generate_text( system_prompt=prompt_config.system, user_prompt=user_prompt, temperature=0.7, use_stream=False, ) # 提取 JSON json_content = self._extract_json(content_text) if json_content: return json_content else: return self._fallback_content(params) except Exception as e: self.log(f"AI 生成失败: {e}", level='warning') return self._fallback_content(params) def _fallback_content(self, params: dict) -> dict: """备用文案生成""" category = params.get("category", "景点") name = params.get("name", "精选推荐") description = params.get("description", "") price = params.get("price", "") features = params.get("features", "") # 提取价格数字和后缀 price_display = "" price_suffix = "" if price: import re match = re.match(r'([¥¥]?\d+(?:\.\d+)?)(.*)', price.replace("元", "")) if match: price_num = match.group(1) if not price_num.startswith("¥"): price_num = "¥" + price_num price_display = price_num suffix_part = match.group(2).strip() if suffix_part: price_suffix = "/" + suffix_part.lstrip("/") else: price_display = price # 处理特色 features_list = [] if features: features_list = [f.strip() for f in features.replace("、", ",").split(",") if f.strip()] return { "title": name, "subtitle": description[:30] if description else f"探索{category}的精彩", "highlights": features_list[:4] if features_list else [], "details": [], "price": price_display, "price_suffix": price_suffix, "tags": [], "suggested_layout": self.CATEGORY_LAYOUT_MAP.get(category, "hero_bottom"), "suggested_theme": self.CATEGORY_THEME_MAP.get(category, "sunset"), } def _generate_preview(self, content: dict, layout: str, theme: Theme, image_url: str = "") -> tuple: """生成预览 PNG (无底图) 并返回 Fabric 对象 Returns: (preview_base64, fabric_objects) """ # 构建 PosterContent poster_content = PosterContent( title=content.get("title", ""), subtitle=content.get("subtitle", ""), price=content.get("price", ""), price_suffix=content.get("price_suffix", ""), highlights=content.get("highlights", []), features=content.get("highlights", []), # 复用 highlights details=content.get("details", []), tags=content.get("tags", []), label=content.get("label", ""), image=None, # 无底图 ) # 获取布局实例 from poster_v2.layouts import ( HeroBottomLayout, OverlayCenterLayout, OverlayBottomLayout, SplitVerticalLayout, CardFloatLayout ) layout_map = { "hero_bottom": HeroBottomLayout, "overlay_center": OverlayCenterLayout, "overlay_bottom": OverlayBottomLayout, "split_vertical": SplitVerticalLayout, "card_float": CardFloatLayout, } layout_class = layout_map.get(layout, HeroBottomLayout) layout_instance = layout_class() # 生成海报 (同时记录 Fabric 对象) # 所有布局都支持 image_url 参数 try: poster_image = layout_instance.generate(poster_content, theme, image_url=image_url) except TypeError: # 兼容旧版布局 poster_image = layout_instance.generate(poster_content, theme) # 获取 Fabric 对象 fabric_objects = layout_instance.get_fabric_objects() # 转 Base64 buffer = io.BytesIO() poster_image.convert("RGB").save(buffer, format="PNG") preview_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") return preview_base64, fabric_objects def _generate_fallback_objects(self, content: dict, layout: str, theme: Theme, image_url: str = "") -> list: """生成后备 Fabric.js 对象 (当布局类没有提供时)""" margin = 48 content_width = self.CANVAS_WIDTH - margin * 2 objects = [{ "id": "background_image", "type": "image", "src": image_url or "", "left": 0, "top": 0, "width": self.CANVAS_WIDTH, "height": self.CANVAS_HEIGHT, "selectable": True, }] # 简化的后备对象 if layout == "hero_bottom": objects.extend(self._fabric_hero_bottom(content, theme, margin, content_width)) elif layout == "overlay_center": objects.extend(self._fabric_overlay_center(content, theme, margin, content_width)) elif layout == "overlay_bottom": objects.extend(self._fabric_overlay_bottom(content, theme, margin, content_width)) elif layout == "split_vertical": objects.extend(self._fabric_split_vertical(content, theme, margin, content_width)) elif layout == "card_float": objects.extend(self._fabric_card_float(content, theme, margin, content_width)) return objects def _fabric_hero_bottom(self, content: dict, theme: Theme, margin: int, content_width: int) -> List[dict]: """hero_bottom 布局的 Fabric 对象""" objects = [] # 渐变遮罩 objects.append({ "id": "gradient_overlay", "type": "rect", "left": 0, "top": 700, "width": self.CANVAS_WIDTH, "height": 740, "fill": { "type": "linear", "coords": {"x1": 0, "y1": 0, "x2": 0, "y2": 740}, "colorStops": [ {"offset": 0, "color": f"rgba({theme.primary_rgb[0]},{theme.primary_rgb[1]},{theme.primary_rgb[2]},0)"}, {"offset": 0.4, "color": f"rgba({theme.primary_rgb[0]},{theme.primary_rgb[1]},{theme.primary_rgb[2]},0.7)"}, {"offset": 1, "color": f"rgba({theme.primary_rgb[0]},{theme.primary_rgb[1]},{theme.primary_rgb[2]},0.95)"}, ] }, "selectable": False, }) cur_y = 900 # 标题 objects.append({ "id": "title", "type": "textbox", "text": content.get("title", ""), "left": margin, "top": cur_y, "width": content_width, "fontSize": 80, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fontWeight": "bold", "fill": theme.text, "shadow": "rgba(0,0,0,0.3) 2px 2px 4px", "selectable": True, }) cur_y += 100 # 副标题 if content.get("subtitle"): objects.append({ "id": "subtitle", "type": "textbox", "text": content.get("subtitle", ""), "left": margin, "top": cur_y, "width": content_width, "fontSize": 36, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fill": f"rgba(255,255,255,0.85)", "selectable": True, }) cur_y += 60 # 价格 if content.get("price"): objects.append({ "id": "price_bg", "type": "rect", "left": margin - 12, "top": cur_y + 40, "width": 200, "height": 60, "rx": 12, "ry": 12, "fill": f"rgba({theme.accent_rgb[0]},{theme.accent_rgb[1]},{theme.accent_rgb[2]},0.3)", "selectable": False, }) objects.append({ "id": "price", "type": "text", "text": content.get("price", ""), "left": margin, "top": cur_y + 48, "fontSize": 48, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fontWeight": "bold", "fill": theme.text, "selectable": True, }) if content.get("price_suffix"): objects.append({ "id": "price_suffix", "type": "text", "text": content.get("price_suffix", ""), "left": margin + 120, "top": cur_y + 65, "fontSize": 28, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fill": f"rgba(255,255,255,0.8)", "selectable": True, }) return objects def _fabric_overlay_center(self, content: dict, theme: Theme, margin: int, content_width: int) -> List[dict]: """overlay_center 布局的 Fabric 对象""" objects = [] # 暗化遮罩 objects.append({ "id": "dark_overlay", "type": "rect", "left": 0, "top": 0, "width": self.CANVAS_WIDTH, "height": self.CANVAS_HEIGHT, "fill": "rgba(0,0,0,0.4)", "selectable": False, }) center_y = self.CANVAS_HEIGHT // 2 - 100 # 装饰线 objects.append({ "id": "deco_line_top", "type": "rect", "left": (self.CANVAS_WIDTH - 80) // 2, "top": center_y - 20, "width": 80, "height": 4, "fill": theme.accent, "selectable": False, }) # 标题 objects.append({ "id": "title", "type": "textbox", "text": content.get("title", ""), "left": margin, "top": center_y + 20, "width": content_width, "fontSize": 96, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fontWeight": "bold", "fill": theme.text, "textAlign": "center", "shadow": "rgba(0,0,0,0.5) 3px 3px 6px", "selectable": True, }) # 副标题 if content.get("subtitle"): objects.append({ "id": "subtitle", "type": "textbox", "text": content.get("subtitle", ""), "left": margin, "top": center_y + 140, "width": content_width, "fontSize": 36, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fill": "rgba(255,255,255,0.85)", "textAlign": "center", "selectable": True, }) return objects def _fabric_overlay_bottom(self, content: dict, theme: Theme, margin: int, content_width: int) -> List[dict]: """overlay_bottom 布局的 Fabric 对象""" objects = [] # 底部毛玻璃区域 glass_y = 750 objects.append({ "id": "glass_bg", "type": "rect", "left": 0, "top": glass_y, "width": self.CANVAS_WIDTH, "height": self.CANVAS_HEIGHT - glass_y, "fill": "rgba(255,255,255,0.92)", "selectable": False, }) cur_y = glass_y + 40 # 标题 objects.append({ "id": "title", "type": "textbox", "text": content.get("title", ""), "left": margin, "top": cur_y, "width": content_width, "fontSize": 72, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fontWeight": "bold", "fill": theme.text_dark, "selectable": True, }) cur_y += 90 # 副标题 if content.get("subtitle"): objects.append({ "id": "subtitle", "type": "textbox", "text": content.get("subtitle", ""), "left": margin, "top": cur_y, "width": content_width, "fontSize": 32, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fill": f"rgba({theme.text_dark_rgb[0]},{theme.text_dark_rgb[1]},{theme.text_dark_rgb[2]},0.7)", "selectable": True, }) cur_y += 60 # 亮点标签 if content.get("highlights"): hl_x = margin for i, hl in enumerate(content.get("highlights", [])[:4]): objects.append({ "id": f"highlight_{i}", "type": "textbox", "text": hl, "left": hl_x, "top": cur_y, "fontSize": 26, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fill": theme.accent, "backgroundColor": f"rgba({theme.accent_rgb[0]},{theme.accent_rgb[1]},{theme.accent_rgb[2]},0.15)", "padding": 12, "selectable": True, }) hl_x += 140 return objects def _fabric_split_vertical(self, content: dict, theme: Theme, margin: int, content_width: int) -> List[dict]: """split_vertical 布局的 Fabric 对象""" objects = [] split = self.CANVAS_WIDTH // 2 # 左侧渐变背景 objects.append({ "id": "left_gradient", "type": "rect", "left": 0, "top": 0, "width": split, "height": self.CANVAS_HEIGHT, "fill": { "type": "linear", "coords": {"x1": 0, "y1": 0, "x2": 0, "y2": self.CANVAS_HEIGHT}, "colorStops": [ {"offset": 0, "color": theme.gradient[0]}, {"offset": 1, "color": theme.gradient[1]}, ] }, "selectable": False, }) # 右侧背景 objects.append({ "id": "right_bg", "type": "rect", "left": split, "top": 0, "width": split, "height": self.CANVAS_HEIGHT, "fill": theme.secondary, "selectable": False, }) content_x = split + margin right_width = split - margin * 2 cur_y = 80 # 标题 objects.append({ "id": "title", "type": "textbox", "text": content.get("title", ""), "left": content_x, "top": cur_y, "width": right_width, "fontSize": 72, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fontWeight": "bold", "fill": theme.text_dark, "selectable": True, }) cur_y += 120 # 装饰线 objects.append({ "id": "deco_line", "type": "rect", "left": content_x, "top": cur_y, "width": 50, "height": 4, "fill": theme.accent, "selectable": False, }) cur_y += 30 # 副标题 if content.get("subtitle"): objects.append({ "id": "subtitle", "type": "textbox", "text": content.get("subtitle", ""), "left": content_x, "top": cur_y, "width": right_width, "fontSize": 32, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fill": f"rgba({theme.text_dark_rgb[0]},{theme.text_dark_rgb[1]},{theme.text_dark_rgb[2]},0.7)", "selectable": True, }) cur_y += 80 # 特色列表 if content.get("highlights"): for i, feature in enumerate(content.get("highlights", [])[:5]): objects.append({ "id": f"feature_{i}", "type": "text", "text": f"· {feature}", "left": content_x, "top": cur_y, "fontSize": 28, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fill": f"rgba({theme.text_dark_rgb[0]},{theme.text_dark_rgb[1]},{theme.text_dark_rgb[2]},0.8)", "selectable": True, }) cur_y += 44 # 价格 if content.get("price"): price_y = self.CANVAS_HEIGHT - 180 objects.append({ "id": "price", "type": "text", "text": content.get("price", ""), "left": content_x, "top": price_y, "fontSize": 72, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fontWeight": "bold", "fill": theme.primary, "selectable": True, }) return objects def _fabric_card_float(self, content: dict, theme: Theme, margin: int, content_width: int) -> List[dict]: """card_float 布局的 Fabric 对象""" objects = [] card_margin = 40 card_y = 500 card_height = 800 # 悬浮卡片 objects.append({ "id": "card_bg", "type": "rect", "left": card_margin, "top": card_y, "width": self.CANVAS_WIDTH - card_margin * 2, "height": card_height, "rx": 28, "ry": 28, "fill": "rgba(255,255,255,0.97)", "shadow": "rgba(0,0,0,0.15) 0px 8px 32px", "selectable": False, }) card_content_x = card_margin + 32 card_content_width = self.CANVAS_WIDTH - card_margin * 2 - 64 cur_y = card_y + 40 # 标签 if content.get("label"): objects.append({ "id": "label", "type": "textbox", "text": content.get("label", ""), "left": card_content_x, "top": cur_y, "fontSize": 24, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fill": theme.accent, "backgroundColor": f"rgba({theme.accent_rgb[0]},{theme.accent_rgb[1]},{theme.accent_rgb[2]},0.15)", "padding": 10, "selectable": True, }) cur_y += 50 # 标题 objects.append({ "id": "title", "type": "textbox", "text": content.get("title", ""), "left": card_content_x, "top": cur_y, "width": card_content_width, "fontSize": 72, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fontWeight": "bold", "fill": theme.text_dark, "selectable": True, }) cur_y += 100 # 副标题 if content.get("subtitle"): objects.append({ "id": "subtitle", "type": "textbox", "text": content.get("subtitle", ""), "left": card_content_x, "top": cur_y, "width": card_content_width, "fontSize": 30, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fill": f"rgba({theme.text_dark_rgb[0]},{theme.text_dark_rgb[1]},{theme.text_dark_rgb[2]},0.7)", "selectable": True, }) cur_y += 70 # 价格 if content.get("price"): objects.append({ "id": "price", "type": "text", "text": content.get("price", ""), "left": card_content_x, "top": card_y + card_height - 100, "fontSize": 64, "fontFamily": "PingFang SC, Microsoft YaHei, sans-serif", "fontWeight": "bold", "fill": theme.primary, "selectable": True, }) return objects def _extract_json(self, text: str) -> Optional[dict]: """从文本中提取 JSON""" import re # 尝试直接解析 try: return json.loads(text) except: pass # 尝试提取 ```json ... ``` 块 json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', text) if json_match: try: return json.loads(json_match.group(1)) except: pass # 尝试提取 {...} 块 brace_match = re.search(r'\{[\s\S]*\}', text) if brace_match: try: return json.loads(brace_match.group()) except: pass return None def _get_ai_agent(self): """获取 AI Agent""" if self._ai_agent is not None: return self._ai_agent try: from core.ai.ai_agent import AIAgent from core.config_loader import get_config from core.config import AIModelConfig config = get_config() ai_config = AIModelConfig( model="qwen-plus", api_url=config.get("ai_model.api_url"), api_key=config.get("ai_model.api_key") or os.environ.get("AI_API_KEY", ""), temperature=0.7, timeout=30000, ) self._ai_agent = AIAgent(ai_config) except Exception as e: self.log(f"获取 AI Agent 失败: {e}", level='warning') self._ai_agent = None return self._ai_agent def _get_poster_factory(self): """获取海报工厂""" if self._poster_factory is None: self._poster_factory = PosterFactory() return self._poster_factory