feat(poster_v2): 实现智能海报引擎 V2
- 新增 poster_smart_v2.py 引擎 - 双输出: preview_base64 (无底图预览) + fabric_json (前端编辑) - 5 种布局的 Fabric.js JSON 生成器 - 复用 AI 文案生成和布局渲染 - 测试脚本和输出样例
This commit is contained in:
parent
fa6b5967f5
commit
5cc31fc733
@ -22,6 +22,7 @@ from .poster_generate_v3 import PosterGenerateEngineV3
|
|||||||
|
|
||||||
# 智能海报引擎 (AI生成文案 + poster_v2)
|
# 智能海报引擎 (AI生成文案 + poster_v2)
|
||||||
from .poster_smart_v1 import PosterSmartEngine
|
from .poster_smart_v1 import PosterSmartEngine
|
||||||
|
from .poster_smart_v2 import PosterSmartEngineV2
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'BaseAIGCEngine',
|
'BaseAIGCEngine',
|
||||||
@ -31,4 +32,5 @@ __all__ = [
|
|||||||
'PosterGenerateEngine',
|
'PosterGenerateEngine',
|
||||||
'PosterGenerateEngineV3',
|
'PosterGenerateEngineV3',
|
||||||
'PosterSmartEngine',
|
'PosterSmartEngine',
|
||||||
|
'PosterSmartEngineV2',
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
BIN
domain/aigc/engines/__pycache__/poster_smart_v2.cpython-312.pyc
Normal file
BIN
domain/aigc/engines/__pycache__/poster_smart_v2.cpython-312.pyc
Normal file
Binary file not shown.
811
domain/aigc/engines/poster_smart_v2.py
Normal file
811
domain/aigc/engines/poster_smart_v2.py
Normal file
@ -0,0 +1,811 @@
|
|||||||
|
#!/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 (无底图)
|
||||||
|
preview_base64 = self._generate_preview(content, layout, theme)
|
||||||
|
|
||||||
|
# 5. 生成 Fabric JSON
|
||||||
|
fabric_json = self._generate_fabric_json(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) -> str:
|
||||||
|
"""生成预览 PNG (无底图)"""
|
||||||
|
factory = self._get_poster_factory()
|
||||||
|
|
||||||
|
# 构建 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, # 无底图
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成海报
|
||||||
|
poster_image = factory.generate_from_content(poster_content, layout=layout, theme=theme.name)
|
||||||
|
|
||||||
|
# 转 Base64
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
poster_image.convert("RGB").save(buffer, format="PNG")
|
||||||
|
return base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||||
|
|
||||||
|
def _generate_fabric_json(self, content: dict, layout: str, theme: Theme, image_url: str = "") -> dict:
|
||||||
|
"""生成 Fabric.js JSON"""
|
||||||
|
objects = []
|
||||||
|
|
||||||
|
# 通用配置
|
||||||
|
margin = 48
|
||||||
|
content_width = self.CANVAS_WIDTH - margin * 2
|
||||||
|
|
||||||
|
# 1. 背景图片占位
|
||||||
|
objects.append({
|
||||||
|
"id": "background_image",
|
||||||
|
"type": "image",
|
||||||
|
"src": image_url or "",
|
||||||
|
"left": 0,
|
||||||
|
"top": 0,
|
||||||
|
"width": self.CANVAS_WIDTH,
|
||||||
|
"height": self.CANVAS_HEIGHT,
|
||||||
|
"scaleX": 1,
|
||||||
|
"scaleY": 1,
|
||||||
|
"selectable": True,
|
||||||
|
"evented": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. 根据布局生成不同的结构
|
||||||
|
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 {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"canvas": {
|
||||||
|
"width": self.CANVAS_WIDTH,
|
||||||
|
"height": self.CANVAS_HEIGHT,
|
||||||
|
"backgroundColor": theme.secondary,
|
||||||
|
},
|
||||||
|
"layout": layout,
|
||||||
|
"theme": theme.name,
|
||||||
|
"objects": 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
|
||||||
158
scripts/test_poster_smart_v2.py
Normal file
158
scripts/test_poster_smart_v2.py
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
测试智能海报生成引擎 V2
|
||||||
|
|
||||||
|
输出:
|
||||||
|
1. preview PNG - 无底图预览
|
||||||
|
2. fabric JSON - 前端编辑用
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from domain.aigc.engines.poster_smart_v2 import PosterSmartEngineV2
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path(__file__).parent.parent / "result" / "poster_smart_v2_test"
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 所有布局
|
||||||
|
ALL_LAYOUTS = ["hero_bottom", "overlay_center", "overlay_bottom", "split_vertical", "card_float"]
|
||||||
|
ALL_THEMES = ["ocean", "sunset", "peach", "mint", "latte"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_all_layouts():
|
||||||
|
"""测试所有布局"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("测试智能海报引擎 V2 - 双输出")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
engine = PosterSmartEngineV2()
|
||||||
|
|
||||||
|
# 每个布局使用不同的测试内容
|
||||||
|
layout_test_data = {
|
||||||
|
"hero_bottom": {
|
||||||
|
"category": "景点", "name": "西湖十景",
|
||||||
|
"description": "杭州最美的风景线,四季皆宜",
|
||||||
|
"price": "免费", "location": "杭州西湖",
|
||||||
|
"features": "湖光山色, 历史古迹, 文化底蕴, 四季美景",
|
||||||
|
"image_url": "https://example.com/xihu.jpg",
|
||||||
|
},
|
||||||
|
"overlay_center": {
|
||||||
|
"category": "活动", "name": "周末露营派对",
|
||||||
|
"description": "逃离城市,拥抱自然",
|
||||||
|
"price": "299元", "location": "从化流溪河",
|
||||||
|
"features": "篝火晚会, 星空观测",
|
||||||
|
"image_url": "https://example.com/camping.jpg",
|
||||||
|
},
|
||||||
|
"overlay_bottom": {
|
||||||
|
"category": "美食", "name": "探店网红甜品",
|
||||||
|
"description": "ins风下午茶打卡地",
|
||||||
|
"price": "人均68元", "location": "深圳万象城",
|
||||||
|
"features": "颜值超高, 味道在线, 出片率满分, 闺蜜必去",
|
||||||
|
"image_url": "https://example.com/dessert.jpg",
|
||||||
|
},
|
||||||
|
"split_vertical": {
|
||||||
|
"category": "民宿", "name": "山舍云端民宿",
|
||||||
|
"description": "藏在莫干山的治愈系民宿,推开窗便是云海与竹林",
|
||||||
|
"price": "458元/晚", "location": "莫干山",
|
||||||
|
"features": "独立庭院, 手冲咖啡, 山景露台, 有机早餐, 管家服务",
|
||||||
|
"image_url": "https://example.com/minsu.jpg",
|
||||||
|
},
|
||||||
|
"card_float": {
|
||||||
|
"category": "酒店", "name": "三亚亚特兰蒂斯",
|
||||||
|
"description": "住进海底世界的浪漫体验",
|
||||||
|
"price": "2888元/晚", "location": "三亚海棠湾",
|
||||||
|
"features": "水族馆景观, 无边泳池, 私人沙滩, 水上乐园",
|
||||||
|
"image_url": "https://example.com/hotel.jpg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, layout in enumerate(ALL_LAYOUTS, 1):
|
||||||
|
theme = ALL_THEMES[i % len(ALL_THEMES)]
|
||||||
|
print(f"\n[{i}] 布局: {layout}, 主题: {theme}")
|
||||||
|
|
||||||
|
test_data = layout_test_data.get(layout, layout_test_data["hero_bottom"])
|
||||||
|
params = {**test_data, "override_layout": layout, "override_theme": theme, "skip_ai": True}
|
||||||
|
|
||||||
|
result = await engine.execute(params)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
print(f" ✓ 成功!")
|
||||||
|
|
||||||
|
# 保存预览 PNG
|
||||||
|
preview_path = OUTPUT_DIR / f"{i:02d}_{layout}_preview.png"
|
||||||
|
with open(preview_path, 'wb') as f:
|
||||||
|
f.write(base64.b64decode(result.data.get('preview_base64')))
|
||||||
|
print(f" 预览 PNG: {preview_path.name}")
|
||||||
|
|
||||||
|
# 保存 Fabric JSON
|
||||||
|
json_path = OUTPUT_DIR / f"{i:02d}_{layout}_fabric.json"
|
||||||
|
with open(json_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(result.data.get('fabric_json'), f, ensure_ascii=False, indent=2)
|
||||||
|
print(f" Fabric JSON: {json_path.name}")
|
||||||
|
|
||||||
|
# 打印内容摘要
|
||||||
|
content = result.data.get('content', {})
|
||||||
|
print(f" 标题: {content.get('title', 'N/A')}")
|
||||||
|
print(f" 对象数: {len(result.data.get('fabric_json', {}).get('objects', []))}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ 失败: {result.error}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"✓ 完成! 输出目录: {OUTPUT_DIR}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_with_ai():
|
||||||
|
"""测试 AI 生成"""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("测试 AI 文案生成")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
engine = PosterSmartEngineV2()
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"category": "景点",
|
||||||
|
"name": "正佳极地海洋世界",
|
||||||
|
"description": "位于广州正佳广场的大型海洋馆,有企鹅、海豚表演",
|
||||||
|
"price": "199元/人",
|
||||||
|
"location": "广州天河",
|
||||||
|
"features": "企鹅馆, 海豚表演, 儿童乐园, 室内恒温",
|
||||||
|
"target_audience": "亲子家庭",
|
||||||
|
"image_url": "https://example.com/ocean.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await engine.execute(params)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
print("✓ AI 生成成功!")
|
||||||
|
content = result.data.get('content', {})
|
||||||
|
print(f" 布局: {result.data.get('layout')}")
|
||||||
|
print(f" 主题: {result.data.get('theme')}")
|
||||||
|
print(f" 标题: {content.get('title')}")
|
||||||
|
print(f" 副标题: {content.get('subtitle')}")
|
||||||
|
print(f" 亮点: {content.get('highlights')}")
|
||||||
|
|
||||||
|
# 保存
|
||||||
|
preview_path = OUTPUT_DIR / "ai_preview.png"
|
||||||
|
with open(preview_path, 'wb') as f:
|
||||||
|
f.write(base64.b64decode(result.data.get('preview_base64')))
|
||||||
|
print(f" 预览: {preview_path.name}")
|
||||||
|
|
||||||
|
json_path = OUTPUT_DIR / "ai_fabric.json"
|
||||||
|
with open(json_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(result.data.get('fabric_json'), f, ensure_ascii=False, indent=2)
|
||||||
|
print(f" JSON: {json_path.name}")
|
||||||
|
else:
|
||||||
|
print(f"✗ 失败: {result.error}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_all_layouts())
|
||||||
|
asyncio.run(test_with_ai())
|
||||||
Loading…
x
Reference in New Issue
Block a user