- Expand poster image model settings with provider, model name, API key, and base URL fields (was only model name + API key) - Add presets for OpenAI (gpt-image-1), Doubao (火山引擎), and Zhipu (CogView) - Refactor image_generator.py to read config from system_settings DB instead of hardcoding gpt-image-1 and OPENAI_API_KEY env var - All OpenAI-compatible image APIs work via configurable base URL - Add doubao and cogview to brand name mapping Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
7.8 KiB
Python
217 lines
7.8 KiB
Python
"""海报图片生成器 — 支持多供应商生图模型(OpenAI / 豆包 / 智谱等)。"""
|
||
import os
|
||
import base64
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 用户尺寸 → API 尺寸映射
|
||
SIZE_MAP = {
|
||
"1080x1920": "1024x1792",
|
||
"900x500": "1792x1024",
|
||
"1080x1080": "1024x1024",
|
||
"800x1200": "1024x1792",
|
||
}
|
||
|
||
|
||
def _load_image_config() -> dict:
|
||
"""从数据库读取海报图片模型配置。"""
|
||
try:
|
||
from insurance.db.compat import db
|
||
from insurance.models.system_setting import SystemSetting
|
||
keys = ["poster_image_provider", "poster_image_model",
|
||
"poster_image_api_key", "poster_image_base_url"]
|
||
settings = {s.key: s.value for s in SystemSetting.query.filter(SystemSetting.key.in_(keys)).all()}
|
||
return settings
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
class PosterImageGenerator:
|
||
"""海报图片生成器,支持 OpenAI 兼容接口的多供应商生图模型。"""
|
||
|
||
def __init__(self):
|
||
self._client = None
|
||
self._config = None
|
||
|
||
def _get_config(self) -> dict:
|
||
if self._config is None:
|
||
self._config = _load_image_config()
|
||
return self._config
|
||
|
||
def _get_client(self):
|
||
if self._client is None:
|
||
import openai
|
||
cfg = self._get_config()
|
||
api_key = cfg.get("poster_image_api_key") or os.getenv("OPENAI_API_KEY", "")
|
||
base_url = cfg.get("poster_image_base_url") or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||
self._client = openai.OpenAI(api_key=api_key, base_url=base_url)
|
||
return self._client
|
||
|
||
def _get_model(self) -> str:
|
||
cfg = self._get_config()
|
||
return cfg.get("poster_image_model") or "gpt-image-1"
|
||
|
||
def build_prompt(self, template: dict = None, product: dict = None,
|
||
company: dict = None, copy: dict = None, size: str = "1024x1792") -> str:
|
||
"""组装完整的生图 prompt。"""
|
||
parts = [
|
||
"你是一位专业的保险营销海报设计师。请根据以下信息生成一张保险营销海报。",
|
||
"",
|
||
]
|
||
|
||
# 风格要求
|
||
if template:
|
||
parts.append("【风格要求】")
|
||
parts.append(template.get("styleDescription", ""))
|
||
if template.get("colorScheme"):
|
||
parts.append(f"配色方案:{template['colorScheme']}")
|
||
parts.append("")
|
||
|
||
# 产品信息
|
||
if product:
|
||
parts.append("【产品信息】")
|
||
parts.append(f"产品名称:{product.get('displayName', '')}")
|
||
parts.append(f"所属公司:{company.get('displayName', '') if company else ''}")
|
||
if product.get("manualParsedRules"):
|
||
rules = product["manualParsedRules"]
|
||
if isinstance(rules, str):
|
||
import json
|
||
try:
|
||
rules = json.loads(rules)
|
||
except Exception:
|
||
rules = {}
|
||
features = rules.get("features", [])
|
||
if features:
|
||
parts.append("产品亮点:" + ";".join(f.get("title", "") for f in features[:3]))
|
||
parts.append("")
|
||
|
||
# 营销文案
|
||
if copy:
|
||
parts.append("【营销文案】")
|
||
if copy.get("headline"):
|
||
parts.append(f"标题:{copy['headline']}")
|
||
if copy.get("body"):
|
||
parts.append(f"正文:{copy['body']}")
|
||
if copy.get("call_to_action"):
|
||
parts.append(f"行动号召:{copy['call_to_action']}")
|
||
parts.append("")
|
||
|
||
# 输出要求
|
||
parts.append("【输出要求】")
|
||
parts.append(f"- 尺寸:{size} 像素")
|
||
parts.append("- 文字清晰可读,中文为主")
|
||
parts.append("- 符合保险行业专业风格")
|
||
parts.append("- 包含公司 Logo 位置(如有)")
|
||
|
||
return "\n".join(parts)
|
||
|
||
def generate(self, prompt: str, size: str = "1024x1792", reference_image: str = None) -> bytes:
|
||
"""调用配置的生图模型生成海报图片。
|
||
|
||
返回:
|
||
PNG 图片的 bytes
|
||
"""
|
||
api_size = SIZE_MAP.get(size, "1024x1792")
|
||
client = self._get_client()
|
||
model = self._get_model()
|
||
|
||
kwargs = {
|
||
"model": model,
|
||
"prompt": prompt,
|
||
"n": 1,
|
||
"size": api_size,
|
||
}
|
||
|
||
# 如果有参考图,尝试传入(仅允许 uploads 目录下的文件)
|
||
uploads_root = os.path.abspath("uploads")
|
||
if reference_image and os.path.exists(reference_image):
|
||
ref_abs = os.path.abspath(reference_image)
|
||
if not ref_abs.startswith(uploads_root):
|
||
logger.warning(f"参考图路径不在允许目录: {reference_image}")
|
||
reference_image = None
|
||
try:
|
||
with open(reference_image, "rb") as img_file:
|
||
response = client.images.edit(
|
||
model=model,
|
||
image=img_file,
|
||
prompt=prompt,
|
||
n=1,
|
||
size=api_size,
|
||
)
|
||
image_base64 = response.data[0].b64_json
|
||
return base64.b64decode(image_base64)
|
||
except Exception as e:
|
||
logger.warning(f"参考图 edit 失败,降级为纯 prompt 生成: {e}")
|
||
|
||
response = client.images.generate(**kwargs)
|
||
image_base64 = response.data[0].b64_json
|
||
return base64.b64decode(image_base64)
|
||
|
||
|
||
def generate_fallback(copy_content: dict, size: str = "1024x1792") -> bytes:
|
||
"""降级方案:使用 Pillow 生成基础排版图。"""
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
w, h = 1024, 1792
|
||
if "x" in size:
|
||
try:
|
||
parts = size.split("x")
|
||
w, h = int(parts[0]), int(parts[1])
|
||
except Exception:
|
||
pass
|
||
|
||
# 缩放到 API 支持的尺寸
|
||
w, h = min(w, 1792), min(h, 1792)
|
||
|
||
img = Image.new("RGB", (w, h), color=(26, 26, 46))
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# 简单排版
|
||
headline = copy_content.get("headline", "")
|
||
body = copy_content.get("body", "")
|
||
cta = copy_content.get("call_to_action", "")
|
||
|
||
# 尝试加载支持中文的字体
|
||
chinese_fonts = [
|
||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||
"/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc",
|
||
"C:/Windows/Fonts/msyh.ttc", # Windows 微软雅黑
|
||
"C:/Windows/Fonts/simsun.ttc", # Windows 宋体
|
||
]
|
||
font_large = font_medium = font_small = None
|
||
for font_path in chinese_fonts:
|
||
if os.path.exists(font_path):
|
||
try:
|
||
font_large = ImageFont.truetype(font_path, 48)
|
||
font_medium = ImageFont.truetype(font_path, 28)
|
||
font_small = ImageFont.truetype(font_path, 22)
|
||
break
|
||
except Exception:
|
||
continue
|
||
if font_large is None:
|
||
font_large = ImageFont.load_default()
|
||
font_medium = ImageFont.load_default()
|
||
font_small = ImageFont.load_default()
|
||
|
||
# 绘制文字
|
||
y = h // 4
|
||
if headline:
|
||
draw.text((w // 2, y), headline, fill="white", font=font_large, anchor="mm")
|
||
y += 80
|
||
if body:
|
||
# 简单换行
|
||
for line in body.split("\n")[:4]:
|
||
draw.text((w // 2, y), line, fill=(200, 200, 200), font=font_medium, anchor="mm")
|
||
y += 40
|
||
if cta:
|
||
draw.text((w // 2, h - 200), cta, fill=(102, 126, 234), font=font_small, anchor="mm")
|
||
|
||
import io
|
||
buf = io.BytesIO()
|
||
img.save(buf, format="PNG")
|
||
return buf.getvalue()
|