jinye_huang 2d21647f10 fix(poster_v2): 确保 Fabric JSON 和 PNG 位置一致
- 在所有布局类中添加 _add_object() 调用
- 渲染时同时记录元素位置到 _fabric_objects
- V2 引擎直接从布局类获取 Fabric 对象
- 移除硬编码的 fallback 位置
2025-12-10 16:28:25 +08:00

832 lines
29 KiB
Python

#!/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