- 在所有布局类中添加 _add_object() 调用 - 渲染时同时记录元素位置到 _fabric_objects - V2 引擎直接从布局类获取 Fabric 对象 - 移除硬编码的 fallback 位置
832 lines
29 KiB
Python
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
|